Files
brain-of-reese/tests/unit/test_search_tool_scope.py
T
ducoterra ab3f5f685b
Build and Push Containers / build-and-push-app (push) Successful in 2m24s
Build and Push Containers / build-and-push-db (push) Successful in 13s
phase: 146_search_tool_scope
Phase 146 final verification pass complete — all green, tree untouched by me.

**Verified (final pass — both tasks already in `complete/`):**
- Scoped grep streams `{"type":"tool","name":"grep","argument":<pattern>,"path":<source/path>}`; bare grep/read/ls frames dump byte-identical (unit matrix in `test_sse_events.py` + builder pins in `test_chat_gate.py` L1364–1448)
- Tool line `🔎 Searching for <pattern> in <source/path>` (two `<code>` spans, `.grep-scope` sibling, no new emoji); bare line byte-identical (unit Node test + E2E)
- Status label "… is searching for X in Y" scoped-only; `path` persisted additively on the saved tool record and re-rendered on `/?chat=<id>` restore; pre-phase records restore byte-identically (E2E tests 2 & 3)

**Results (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **3419 passed**, `app/` coverage **99%** (>90% ✓)
- `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` → **3 passed** in isolation ✓
- `uv run ruff check . && uv run pyright` → clean (0 errors) ✓

**Completion criteria:** all 6 ✓ (wire shape, UI line, status label, save/restore, gates, no completed-phase behavior change — full suite green includes all prior-phase pins).

**Documentation:** README.md (incl. its phase-146 section) and AGENTS.md reviewed in full against the codebase — verified consistent, no changes needed (`.env.example` ⊆ README config tables; no stale vars; phase-100 72rem rule in AGENTS.md §5 matches shipped CSS; no-CDN/smoke-DB/gpgsign claims all confirmed).

**Notable:** none — no defects found, no live-infrastructure touched, nothing committed per harness protocol.

**Next pending phase:** `147_sync_changed_only`.
2026-10-01 12:48:04 -04:00

531 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 <scope>`` 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 ``<code>``
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
``<code>`` — 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
``<code>`` child — the pinned ``🔎 Searching for `` + ``<code>``
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 <code> 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 <code> 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 <pattern> in <source/path>`` — the scope rides
the template's additive `` in <scope>`` 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 <code> 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 <code>pattern</code>``, a
single ``code`` child, NO ``.grep-scope`` span; a SCOPED grep
appends the ``.grep-scope`` span AFTER the pattern ``<code>``
holding the `` in `` text node + the second ``<code>`` (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 <code> 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 <code> 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 <code> 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"]