"""Unit: the phase-146 (task 02) "the search tool shows WHERE" frontend + record contract. The wire half (the ``ChatToolEvent`` additive ``path`` + the frame builder) is pinned by phase 146 task 01 (``test_sse_events.py`` / ``test_chat_gate.py``). This task's behavior lives in ``frontend/assets/app.js`` (``appendToolLine``'s additive ``scope`` param + the ``.grep-scope`` span, the live ``tool`` branch's ``ev.path`` read + the ``toolRecord.path`` stamp + the status label's `` in `` extension, the restore path's ``t.path`` pass-through) and ``app/schemas.py`` (the saved record's additive ``path`` on ``ToolCall`` — the persistence boundary that carries the scope from the live record through the ``bor.chat.v1`` JSONB to the restore path). Like the other frontend-adjacent unit files (``test_read_range_tool_lines.py`` is the phase-139 precedent, ``test_frontend_tool_states.py`` the phase-37 one), this module pins the JS markers the story depends on so a silent regression in the scope wording, the string guard, the persistence shape, or the byte-identity of bare greps is caught without a browser. A small node-driven behavioral check (skipped when node is unavailable, the phase-139 pattern) proves the scoped line's structure (two ```` spans + the `` in `` text node) and the bare line's byte-identity. The E2E gate is the phase's story suite (``tests/e2e/test_search_tool_scope.py``). """ from __future__ import annotations import json import shutil import subprocess from pathlib import Path import pytest from pydantic import ValidationError from app.schemas import ChatMessage, ToolCall FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" HAVE_NODE = shutil.which("node") is not None #: The saved record's scope cap (mirrors ``argument`` — the combined #: ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is the same #: ×2 headroom). PATH_CAP = 2000 def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _extract(js: str, name: str) -> str: """One top-level function by name — from its ``function NAME`` to its column-0 closing ``}`` (the house extraction convention).""" fn = js.find(f"function {name}") assert fn != -1, f"{name} not found" end = js.find("\n}\n", fn) assert end != -1, f"could not find the end of {name}" return js[fn : end + 2] def _live_branch(js: str) -> str: """The live ``tool`` SSE branch (up to the ``delta`` branch).""" tool_idx = js.find('ev.type === "tool"') delta_idx = js.find('ev.type === "delta"') assert -1 < tool_idx < delta_idx return js[tool_idx:delta_idx] def _restore_body(js: str) -> str: """The phase-14 restore path (``renderStoredMessage``).""" fn = js.find("function renderStoredMessage") assert fn != -1 end = js.find("function restoreConversation") assert fn < end return js[fn:end] def _run_node(script: str) -> str: proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60) assert proc.returncode == 0, f"node failed: {proc.stderr}" return proc.stdout # -------------------------------------------------------------------------- # app.js — appendToolLine: the .grep-scope span on the Searching line # -------------------------------------------------------------------------- def test_append_tool_line_signature_takes_optional_scope() -> None: """appendToolLine gains the additive ``scope`` param (fifth, default ``null`` — a pre-phase 5-arg call renders byte-identical), and the grep arm appends the scope span through the pinned structure (a ``.grep-scope`` span SIBLING after the pattern ```` — the phase-139 read-range-suffix precedent).""" js = _js() assert ( "function appendToolLine(wrap, name, argument, offset = null, " "limit = null, scope = null)" in js ), "the signature carries the additive scope (default null)" body = _extract(js, "appendToolLine") assert "if (typeof scope === \"string\" && scope)" in body, ( "the span is appended ONLY when the scope is a non-empty string" ) assert 'scopeSpan.className = "grep-scope"' in body, ( "the scope rides a .grep-scope span" ) assert 'document.createTextNode(" in ")' in body, ( "the ` in ` join is a text node (the scope is data, never markup)" ) assert "scopeCode.textContent = scope" in body, ( "the scope is data — textContent, never innerHTML" ) def test_grep_scope_span_is_a_sibling_after_the_pattern_code() -> None: """The .grep-scope span is a SIBLING appended AFTER the pattern ```` child — the pinned ``🔎 Searching for `` + ```` bytes are untouched (the phase-70/87/117 label pins + the frontend emoji guard stay green; no new emoji).""" body = _extract(_js(), "appendToolLine") assert 'line.textContent = "🔎 Searching for "' in body, ( "the pinned grep label literal is untouched" ) # Inside the grep arm: the pattern is appended first, the # scope span after it. grep_arm = body.find("🔎 Searching for ") ls_arm = body.find("🔎 Listing documents in ") assert -1 < grep_arm < ls_arm arm = body[grep_arm:ls_arm] code_append = arm.find("line.appendChild(code)") scope_append = arm.find("line.appendChild(scopeSpan)") assert -1 < code_append < scope_append, ( "the scope span lands AFTER the pattern child" ) assert "innerHTML" not in body, ( "no HTML injection surface on tool lines — textContent only" ) def test_only_the_grep_arm_reads_the_scope() -> None: """The scope param feeds ONLY the grep arm: the read / ls / listing arms never consult it (a scope on a non-grep call renders exactly the pre-phase line — no .grep-scope span outside the grep arm).""" body = _extract(_js(), "appendToolLine") grep_arm = body.find("🔎 Searching for ") ls_arm = body.find("🔎 Listing documents in ") guard = body.find("typeof scope === \"string\" && scope") # The scope guard appears exactly once in the function — inside the # grep arm (after the Searching label, before the ls arm). assert body.count("typeof scope === \"string\" && scope") == 1 assert -1 < grep_arm < guard < ls_arm, ( "the scope guard sits inside the grep arm only" ) # The read arm renders the range suffix only — never the scope. read_arm_end = body.find("🔎 Searching for ") read_arm = body[:read_arm_end] assert "scopeSpan" not in read_arm # -------------------------------------------------------------------------- # app.js — the live `tool` branch: ev.path + the toolAcc stamp + label # -------------------------------------------------------------------------- def test_live_tool_branch_reads_the_scope_with_the_guard() -> None: """The live ``tool`` branch reads ``ev.path`` with the SAME string guard as ``argument`` (``typeof ev.path === "string" && ev.path`` → ``null`` when absent / empty / malformed), and the record gains ``path`` ONLY when non-null — a bare grep keeps the exact pre-phase ``{name, argument}`` shape (the additive ``bor.chat.v1`` JSONB — old records restore byte-identically, no migration).""" branch = _live_branch(_js()) assert ( "typeof ev.path === \"string\" && ev.path ? ev.path : null" in branch ), "ev.path reads through the house string guard → null when absent" assert "const toolRecord = { name, argument }" in branch, ( "the base record stays {name, argument}-generic" ) assert "if (framePath !== null) toolRecord.path = framePath" in branch, ( "path joins the record ONLY when non-null (additive key)" ) assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded" # The scope rides the SAME helper (the line's fifth argument). assert ( "appendToolLine(wrap, name, argument, frameOffset, frameLimit, " "framePath)" in branch ) def test_live_grep_status_carries_the_scope() -> None: """The grep status label shows WHERE the search runs: ``… is searching for in `` — the scope rides the template's additive `` in `` extension (empty-string fallback when the frame carried none, so a bare grep renders the EXACT pre-phase label). The read / ls labels are untouched.""" branch = _live_branch(_js()) assert ( "`${brand()} is searching for ${argument}" "${framePath ? ` in ${framePath}` : \"\"}`" in branch ), "the grep status carries the scope (the in-scope extension)" # The read / ls labels are byte-identical to pre-phase. assert ( "`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`" in branch ) assert "`${brand()} is listing documents in ${argument}`" in branch assert "`${brand()} is listing documents`" in branch # The OLD bare-grep label literal is gone (superseded by the # additive template) — the byte-identity lives in the template's # empty-string fallback (pinned behaviorally under node below). assert "`${brand()} is searching for ${argument}`" not in branch # -------------------------------------------------------------------------- # app.js — the restore path: the stored scope re-renders the line # -------------------------------------------------------------------------- def test_restore_path_passes_the_stored_scope_through() -> None: """The phase-14 restore path (renderStoredMessage): a stored tool record with ``path`` (the live ``toolAcc`` stamp — additive on ``bor.chat.v1``) re-renders the SAME scope through the SAME helper; a record saved before the phase (no ``path`` key) renders byte-identically (the guard → null → no span).""" body = _restore_body(_js()) assert ( "typeof t.path === \"string\" && t.path ? t.path : null" in body ), "the stored scope reads through the house string guard" assert ( "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body ), "the stored scope rides the SAME helper" # -------------------------------------------------------------------------- # app/schemas.py — ToolCall.path: the persistence boundary # -------------------------------------------------------------------------- def test_tool_call_path_absent_by_default_and_omitted() -> None: """A tool record without ``path`` (every pre-phase record, every bare grep) validates UNCHANGED and serializes WITHOUT the key — ABSENT, never null: the saved shape stays byte-identical to pre-phase (the phase-50 backward-compat rule, no migration).""" call = ToolCall.model_validate({"name": "grep", "argument": "p"}) assert call.path is None dumped = call.model_dump() assert "path" not in dumped, dumped # The full pre-phase key set is exactly what round-trips. assert set(dumped) == { "name", "argument", "truncated", "chars_shown", "chars_total", "offset", "limit", }, dumped def test_tool_call_path_round_trips_when_set() -> None: """A SCOPED grep's record carries ``path`` (the re-searched document's combined ``source/path``) through validate → dump — the save → restore round-trip keeps it, so the restore path and the shared page can re-render the scope.""" call = ToolCall.model_validate( {"name": "grep", "argument": "p", "path": "search_docs/reese-notes.md"} ) assert call.path == "search_docs/reese-notes.md" assert call.model_dump()["path"] == "search_docs/reese-notes.md" def test_tool_call_path_at_cap_validates() -> None: call = ToolCall.model_validate({"name": "grep", "argument": "p", "path": "a" * PATH_CAP}) assert len(call.path or "") == PATH_CAP def test_tool_call_path_one_over_cap_rejects() -> None: with pytest.raises(ValidationError) as exc: ToolCall.model_validate( {"name": "grep", "argument": "p", "path": "a" * (PATH_CAP + 1)} ) assert any(e["loc"] == ("path",) for e in exc.value.errors()), exc.value.errors() def test_chat_message_scoped_tool_record_crosses_the_boundary() -> None: """The full message boundary (``extra="forbid"`` on ``ChatMessage``): ``path`` is an ACCEPTED key on the nested tool record — a scoped grep's brain record does not 422 at the anonymous write surface (the phase-113 lesson: a missing key silently unsaves the record), and the dump keeps it.""" msg = ChatMessage.model_validate( { "who": "brain", "text": "ok", "tools": [ {"name": "grep", "argument": "p", "path": "search_docs/reese-notes.md"}, {"name": "grep", "argument": "p"}, ], } ) dumped = msg.model_dump() assert dumped["tools"][0]["path"] == "search_docs/reese-notes.md" assert "path" not in dumped["tools"][1], dumped["tools"][1] # -------------------------------------------------------------------------- # behavior (node): the scoped line's structure + the bare line's # byte-identity # -------------------------------------------------------------------------- #: A minimal DOM stub — the phase-139 stub plus ``createTextNode`` #: (the .grep-scope span holds a text node + a child). Every #: node is snapshotted as a JSON row (the test-document-viewer #: pattern). _DOM_STUB = """ class TextNode { constructor(text) { this._text = String(text); } get textContent() { return this._text; } } class El { constructor(tag) { this.tag = tag; this.children = []; this.attrs = {}; this.className = ""; this._text = ""; this.open = false; } setAttribute(k, v) { this.attrs[k] = String(v); } getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; } appendChild(c) { this.children.push(c); return c; } insertBefore(c, ref) { if (!ref) { this.children.push(c); return c; } const i = this.children.indexOf(ref); if (i === -1) this.children.push(c); else this.children.splice(i, 0, c); return c; } _match(el, sel) { if (sel.charAt(0) === ".") return (el.className || "").split(/\\s+/).indexOf(sel.slice(1)) !== -1; if (sel.charAt(0) === "#") return el.id === sel.slice(1); return el.tag === sel; } querySelector(sel) { for (const c of this.children) { if (this._match(c, sel)) return c; const r = c.querySelector(sel); if (r) return r; } return null; } querySelectorAll(sel) { const out = []; for (const c of this.children) { if (this._match(c, sel)) out.push(c); out.push(...c.querySelectorAll(sel)); } return out; } get textContent() { let t = this._text; for (const c of this.children) t += c.textContent; return t; } set textContent(v) { this._text = String(v); this.children = []; } } const document = { createElement: (t) => new El(t), createTextNode: (t) => new TextNode(t), }; function makeWrap() { const wrap = document.createElement("div"); const body = document.createElement("div"); body.className = "msg-body"; const bubble = document.createElement("div"); bubble.className = "bubble"; body.appendChild(bubble); wrap.appendChild(body); return wrap; } function describe(el) { return { text: el.textContent, children: el.children.map( (c) => c.tag ? { tag: c.tag, cls: c.className, text: c.textContent, children: describe(c).children, } : { text: c.textContent } ), }; } """ @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_scoped_grep_line_and_bare_byte_identity_under_node() -> None: """The REAL app.js ``appendToolLine`` under node (against the DOM stub): a BARE grep (no scope — including an empty string and a non-string scope, the guard's fallbacks) renders the pre-phase line byte-identically — ``🔎 Searching for pattern``, a single ``code`` child, NO ``.grep-scope`` span; a SCOPED grep appends the ``.grep-scope`` span AFTER the pattern ```` holding the `` in `` text node + the second ```` (the scope, data, never markup). The legacy name (``search_documents``) keeps the same byte-identity for a bare call.""" append = _extract(_js(), "appendToolLine") driver = ( _DOM_STUB + "\n" + append + '\nconst PATTERN = "reese-sentinel-42";\n' + 'const SCOPE = "search_docs/reese-notes.md";\n' # bare grep (no scope) + "const w0 = makeWrap();\n" + 'appendToolLine(w0, "grep", PATTERN);\n' + 'const bare = describe(w0.querySelector(".tool-call"));\n' # bare grep, legacy name (no scope) + "const w1 = makeWrap();\n" + 'appendToolLine(w1, "search_documents", PATTERN);\n' + 'const legacy = describe(w1.querySelector(".tool-call"));\n' # guarded fallbacks: empty string + non-string scope + "const w2 = makeWrap();\n" + 'appendToolLine(w2, "grep", PATTERN, null, null, "");\n' + 'const emptyScope = describe(w2.querySelector(".tool-call"));\n' + "const w3 = makeWrap();\n" + 'appendToolLine(w3, "grep", PATTERN, null, null, 42);\n' + 'const badScope = describe(w3.querySelector(".tool-call"));\n' # the scoped grep + "const w4 = makeWrap();\n" + 'appendToolLine(w4, "grep", PATTERN, null, null, SCOPE);\n' + 'const scoped = describe(w4.querySelector(".tool-call"));\n' "console.log(JSON.stringify(" "{ bare, legacy, emptyScope, badScope, scoped }));" ) out = json.loads(_run_node(driver)) bare, legacy, empty_scope, bad_scope, scoped = ( out["bare"], out["legacy"], out["emptyScope"], out["badScope"], out["scoped"], ) # The bare line: byte-identical to pre-phase — ONE child, # no .grep-scope span, the exact pinned text. assert bare["text"] == "🔎 Searching for reese-sentinel-42", bare assert bare["children"] == [ { "tag": "code", "cls": "", "text": "reese-sentinel-42", "children": [], } ], bare # The legacy-name bare call renders the SAME bytes (no migration). assert legacy == bare, (legacy, bare) # The guard's fallbacks (empty string / non-string) → the bare line. assert empty_scope == bare, (empty_scope, bare) assert bad_scope == bare, (bad_scope, bare) # The scoped line: the pinned prefix + pattern, then the scope span. assert ( scoped["text"] == "🔎 Searching for reese-sentinel-42 in search_docs/reese-notes.md" ), scoped assert scoped["children"][0] == { "tag": "code", "cls": "", "text": "reese-sentinel-42", "children": [], }, scoped span = scoped["children"][1] assert span["tag"] == "span" and span["cls"] == "grep-scope", scoped assert span["text"] == " in search_docs/reese-notes.md", scoped # TWO spans (pattern, then scope) with the " in " text node # between them — the textContent structure, not a regex over the # whole line (the phase-139 range-suffix pin style). assert span["children"][0] == {"text": " in "}, scoped assert span["children"][1] == { "tag": "code", "cls": "", "text": "search_docs/reese-notes.md", "children": [], }, scoped # Document order of the line's elements: the pattern first # (the direct child), then the scope (inside the span). codes = [scoped["children"][0]["text"]] + [ c["text"] for c in span["children"] if c.get("tag") == "code" ] assert codes == ["reese-sentinel-42", "search_docs/reese-notes.md"], scoped @pytest.mark.skipif(not HAVE_NODE, reason="node not available") def test_non_grep_arms_ignoring_the_scope_stay_byte_identical() -> None: """A non-grep call that somehow carried a scope (a read / an ls / the plain listing) renders EXACTLY the pre-phase line — the scope param feeds only the grep arm, so no .grep-scope span can leak into the other three lines.""" append = _extract(_js(), "appendToolLine") range_suffix = _extract(_js(), "readRangeSuffix") driver = ( _DOM_STUB + "\n" + range_suffix + "\n" + append + "const w0 = makeWrap();\n" + 'appendToolLine(w0, "read", "s/d.md", null, null, "x");\n' + 'const readLine = describe(w0.querySelector(".tool-call"));\n' + "const w1 = makeWrap();\n" + 'appendToolLine(w1, "ls", "s", null, null, "x");\n' + 'const lsLine = describe(w1.querySelector(".tool-call"));\n' + "const w2 = makeWrap();\n" + 'appendToolLine(w2, "ls", null, null, null, "x");\n' + 'const plainLine = describe(w2.querySelector(".tool-call"));\n' "console.log(JSON.stringify({ readLine, lsLine, plainLine }));" ) out = json.loads(_run_node(driver)) for line in (out["readLine"], out["lsLine"], out["plainLine"]): assert all(c.get("cls") != "grep-scope" for c in line["children"]), line assert out["readLine"]["text"] == "📄 Reading s/d.md", out["readLine"] assert out["lsLine"]["text"] == "🔎 Listing documents in s", out["lsLine"] assert out["plainLine"]["text"] == "🔎 Listing documents", out["plainLine"]