phase: 146_search_tool_scope
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 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`.
This commit is contained in:
2026-10-01 12:48:04 -04:00
parent 32a3ec4f5e
commit ab3f5f685b
27 changed files with 2151 additions and 29 deletions
+530
View File
@@ -0,0 +1,530 @@
"""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"]