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
+3 -1
View File
@@ -197,7 +197,9 @@ def test_arm_has_exactly_one_live_call_site() -> None:
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
append = branch.find("appendToolLine(wrap, name, argument, frameOffset, frameLimit)")
append = branch.find(
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
)
arm = branch.find("armToolLineClock(wrap)")
assert -1 < append < arm, (
"the arm follows the line's append — the baseline resets per line"
+161
View File
@@ -934,6 +934,40 @@ class _CannedLLM:
yield StreamPiece("content", self.answer[i : i + 12])
class _ToolCallCannedLLM(_CannedLLM):
"""Streams ONE tool call (the given name + arguments) on the first
request, then the canned answer — the phase-146 (task 01) frame-
builder driver: the endpoint's ``ToolCallPiece`` branch (the
``_pump`` loop in ``app.api.chat``) is the ONE extraction path
every ``tool`` frame goes through, so the scope guard is pinned
through the real endpoint (no parallel re-implementation)."""
def __init__(
self, tool_name: str, arguments: dict[str, Any], answer: str = ANSWER
) -> None:
super().__init__(answer=answer)
self.tool_name = tool_name
self.arguments = arguments
self._emitted = False
async def chat_stream(
self,
messages: list[dict[str, str]],
tools: list[dict[str, Any]] | None = None,
scaffolding: ScaffoldingFilter | None = None, # phase 71 pass-through
):
self.seen.append(messages)
self.seen_tools.append(tools)
if not self._emitted:
self._emitted = True
yield ToolCallPiece(
id="call_1", name=self.tool_name, arguments=self.arguments
)
return
for i in range(0, len(self.answer), 12):
yield StreamPiece("content", self.answer[i : i + 12])
class _FakeSteeringResult:
"""Empty steering-note result (no stored notes in these unit tests)."""
@@ -1324,6 +1358,133 @@ def test_endpoint_suggested_folder_lines_reach_the_high_prompt(
)
# ---------- endpoint: grep scope frame (phase 146, task 01) ----------
def test_endpoint_scoped_grep_frame_carries_path(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 146 (task 01): a SCOPED grep (``grep(pattern, path)``)
streams a ``tool`` frame that carries the scope — the re-searched
document's combined ``source/path`` — as the additive ``path``
field, with ``argument`` still the model's ``pattern`` (the ONLY
case the field is present on the wire)."""
_session, _llm = gate_env
doc = _doc("The Ops Handbook", "page one\nthe grep me line\npage three")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
llm = _ToolCallCannedLLM("grep", {"pattern": "grep me", "path": f"Homelab/{doc.path}"})
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
monkeypatch.setattr(
rag_agent,
"find_document",
_find_docs({(doc.source, doc.path): doc}),
)
frames = _ask(client, "Where does the handbook mention grep me?")
tools = [f for f in frames if f["type"] == "tool"]
assert tools == [
{
"type": "tool",
"name": "grep",
"argument": "grep me",
"path": f"Homelab/{doc.path}",
}
]
assert frames[-1]["type"] == "done"
assert frames[-1]["deflected"] is False
assert len(llm.seen) == 2 # the grep round + the answer round
def test_endpoint_bare_grep_frame_has_no_path_key(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 146 (task 01): a bare grep's frame is byte-identical to
pre-phase — the ``path`` key is ABSENT (never ``null``) and no
other frame of the turn gains a key either."""
_session, _llm = gate_env
doc = _doc("The Ops Handbook", "content")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
llm = _ToolCallCannedLLM("grep", {"pattern": "grep me"})
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
monkeypatch.setattr(rag_agent, "all_documents", lambda _db: [])
frames = _ask(client, "Search the whole KB for grep me")
tools = [f for f in frames if f["type"] == "tool"]
assert tools == [{"type": "tool", "name": "grep", "argument": "grep me"}]
assert all("path" not in f for f in frames)
assert frames[-1]["type"] == "done"
def test_endpoint_grep_non_string_path_scope_omitted(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 146 (task 01): a NON-string ``path`` (the model-error
shape — a list here; the agent refuses the call) never reaches the
frame: the builder's string guard yields ``None`` → the key is
omitted, so the frame is the exact bare-grep shape."""
_session, _llm = gate_env
doc = _doc("The Ops Handbook", "content")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
llm = _ToolCallCannedLLM("grep", {"pattern": "grep me", "path": ["s", "d.md"]})
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
monkeypatch.setattr(rag_agent, "all_documents", lambda _db: [])
frames = _ask(client, "Search the whole KB for grep me")
tools = [f for f in frames if f["type"] == "tool"]
assert tools == [{"type": "tool", "name": "grep", "argument": "grep me"}]
assert all("path" not in f for f in frames)
assert frames[-1]["type"] == "done"
def test_endpoint_read_and_ls_frames_never_carry_scope(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 146 (task 01): ``read`` and ``ls`` NEVER read a scope from
their arguments — even though their ``path`` argument IS a string
(the combined ``source/path`` / the source name), it rides
``argument`` (the pre-phase wire shape) and the frame has no
``path`` key."""
_session, _llm = gate_env
doc = _doc("The Ops Handbook", "content")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
monkeypatch.setattr(
rag_agent,
"find_document",
_find_docs({(doc.source, doc.path): doc}),
)
monkeypatch.setattr(rag_agent, "list_source_names", lambda _db: [])
# A plain read (its ``path`` argument is a string — argument, not
# scope; the frame stays the exact pre-phase shape).
llm = _ToolCallCannedLLM("read", {"path": f"Homelab/{doc.path}"})
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
frames = _ask(client, "What does the handbook say?")
reads = [f for f in frames if f["type"] == "tool"]
assert reads == [{"type": "tool", "name": "read", "argument": f"Homelab/{doc.path}"}]
assert all("path" not in f for f in frames)
assert frames[-1]["type"] == "done"
# A scoped ls (same — the argument is the source-name string).
llm = _ToolCallCannedLLM("ls", {"path": "Homelab"})
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
frames = _ask(client, "List my sources")
lss = [f for f in frames if f["type"] == "tool"]
assert lss == [{"type": "tool", "name": "ls", "argument": "Homelab"}]
assert all("path" not in f for f in frames)
assert frames[-1]["type"] == "done"
# ---------- endpoint: KB overview row (phase 31) ----------
+17 -6
View File
@@ -62,7 +62,10 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None:
assert "uiState === UI_STATE.thinking" in branch, (
"label updates only while the state is still thinking"
)
assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch
assert (
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
in branch
)
# No setUiState in the branch: the state stays "thinking" (never stale).
assert "setUiState" not in branch, (
"the tool branch must keep uiState=thinking (button stays disabled)"
@@ -98,9 +101,15 @@ def test_calling_tool_label_strings() -> None:
"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.
# branch and the listing fallback in the ternary. Phase 146
# (task 02): the label gains the additive scope — `` in <path>``
# ONLY when the frame carried one (a bare grep renders the exact
# pre-phase label — the template's empty-string fallback).
assert 'name === "grep" || name === "search_documents") && argument' in branch
assert "`${brand()} is searching for ${argument}`" in branch
assert (
"`${brand()} is searching for ${argument}"
"${framePath ? ` in ${framePath}` : \"\"}`" 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
@@ -227,9 +236,11 @@ def test_tool_lines_re_render_on_restore() -> None:
end = js.find("function restoreConversation")
body = js[fn:end]
assert "Array.isArray(m.tools)" in body
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, (
"phase 139: the stored range rides the SAME helper — a pre-phase"
" record (no fields) renders byte-identical"
assert (
"appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
), (
"phase 139: the stored range + the phase-146 stored scope ride the "
"SAME helper — a pre-phase record (no fields) renders byte-identical"
)
thinking_restore = body.find("if (m.thinking)")
tools_restore = body.find("Array.isArray(m.tools)")
+24 -11
View File
@@ -113,12 +113,15 @@ def test_read_range_suffix_shape_order() -> None:
def test_append_tool_line_signature_takes_optional_range() -> None:
"""appendToolLine gains the additive ``offset``/``limit`` params
(default ``null`` — a pre-phase 3-arg call renders byte-identical),
and the read arm renders the suffix through the SAME helper."""
and the read arm renders the suffix through the SAME helper.
Phase 146 (task 02) extends the signature one more additive param
(``scope = null`` — the scoped grep's document) — the 5-arg
pre-phase-146 call shape renders byte-identically."""
body = _extract(_js(), "appendToolLine")
assert (
"function appendToolLine(wrap, name, argument, offset = null, limit = null)"
in _js()
), "the signature carries the additive offset/limit (default null)"
"function appendToolLine(wrap, name, argument, offset = null, limit = null, "
"scope = null)" in _js()
), "the signature carries the additive offset/limit/scope (default null)"
assert "readRangeSuffix(offset, limit)" in body, (
"the read arm renders the suffix through the SAME helper"
)
@@ -185,15 +188,21 @@ def test_live_tool_branch_reads_the_range_with_the_guard() -> None:
"limit joins the record ONLY when non-null"
)
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
# Both values pass to the SAME helper (the line + the status label).
assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch
# Phase 146 (task 02): the scope joins the SAME two pass-throughs.
assert (
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
in branch
)
def test_live_read_status_carries_the_range_suffix() -> None:
"""ASSUMPTION A2: the read status label carries the SAME
requested-range suffix as the line (``readRangeSuffix`` — the single
source of truth); a non-range read renders byte-identical (the
helper returns ``""``). The grep / ls labels are untouched."""
helper returns ``""``). The ls labels are untouched; the grep label
gains the phase-146 additive scope (`` in <path>`` ONLY when the
frame carried one — a bare grep renders the exact pre-phase label).
"""
js = _js()
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
@@ -202,8 +211,12 @@ def test_live_read_status_carries_the_range_suffix() -> None:
"`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`"
in branch
), "the read status carries the suffix (A2)"
# The other labels stay byte-identical (no suffix).
assert "`${brand()} is searching for ${argument}`" in branch
# The other labels: the grep scope extension (phase 146) + the
# ls labels untouched.
assert (
"`${brand()} is searching for ${argument}"
"${framePath ? ` in ${framePath}` : \"\"}`" in branch
)
assert "`${brand()} is listing documents in ${argument}`" in branch
assert "`${brand()} is listing documents`" in branch
@@ -230,8 +243,8 @@ def test_restore_path_passes_the_stored_range_through() -> None:
assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body, (
"the stored limit reads through the house guard"
)
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, (
"the stored range rides the SAME helper"
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body, (
"the stored range + the phase-146 stored scope ride the SAME helper"
)
+2 -2
View File
@@ -163,7 +163,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
assert fn != -1
end = js.find("function restoreConversation")
body = js[fn:end]
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
assert "t.truncated && arg" in body, (
"only an argument-bearing (Reading) record with the flag renders"
" the marker"
@@ -173,7 +173,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
" Number(t.chars_total) || 0)" in body
)
# The marker append sits INSIDE the tools loop, after the line append.
line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)")
line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
note_idx = body.find("appendTruncatedNote(wrap, arg,")
assert -1 < line_idx < note_idx
+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"]
+63
View File
@@ -167,6 +167,69 @@ def test_tool_event_range_fields_absent_not_null_when_omitted() -> None:
assert set(dumped.keys()) == {"type", "name", "argument"}, dumped
def test_tool_frame_scoped_grep_carries_path() -> None:
"""Phase 146 (task 01): a SCOPED grep's frame carries the scope —
the re-searched document's combined ``source/path`` — as the
additive ``path`` field (the ONLY case the field is present on the
wire), AFTER ``argument`` (the model's ``pattern``)."""
frame = sse_event(
ChatToolEvent(name="grep", argument="pattern", path="srcs/doc.md").model_dump()
)
assert frame == (
'data: {"type": "tool", "name": "grep", "argument": "pattern", '
'"path": "srcs/doc.md"}\n\n'
)
assert _payload(frame) == {
"type": "tool",
"name": "grep",
"argument": "pattern",
"path": "srcs/doc.md",
}
def test_tool_frame_bare_grep_has_no_path_key() -> None:
"""Phase 146 (task 01): a bare grep's frame has NO ``path`` key at
all — ABSENT, never ``null`` (the omission pattern; a ``null`` value
would change the pre-phase wire bytes)."""
dumped = ChatToolEvent(name="grep", argument="pattern").model_dump()
assert "path" not in dumped
assert dumped == {"type": "tool", "name": "grep", "argument": "pattern"}
def test_tool_frame_non_scoped_tools_dump_the_exact_pre_phase_shape() -> None:
"""Phase 146 (task 01): every frame that is not a scoped grep —
``read`` with and without a range, ``ls`` with and without an
argument — dumps to the EXACT pre-phase dict (the equality pins:
byte-identity of the non-scoped frames is the point of the
additive-omission serializer). A non-string ``path`` never reaches
the model at all: the frame builder (``app.api.chat``) string-guards
it to ``None`` before construction (pinned at the builder level in
``tests/unit/test_chat_gate.py``)."""
assert (
ChatToolEvent(name="read", argument="S/p.md").model_dump()
== {"type": "tool", "name": "read", "argument": "S/p.md"}
)
assert (
ChatToolEvent(name="read", argument="S/p.md", offset=1500, limit=30).model_dump()
== {
"type": "tool",
"name": "read",
"argument": "S/p.md",
"offset": 1500,
"limit": 30,
}
)
assert ChatToolEvent(name="ls").model_dump() == {
"type": "tool",
"name": "ls",
"argument": None,
}
assert (
ChatToolEvent(name="ls", argument="Homelab").model_dump()
== {"type": "tool", "name": "ls", "argument": "Homelab"}
)
def test_retry_frame_serializes_exactly() -> None:
"""Phase 67: the ``retry`` frame is exactly ``{type: "retry",
attempt: int, max_attempts: int}`` — a transient status the client's
+2 -2
View File
@@ -136,10 +136,10 @@ def test_restore_path_folds_the_disclosure() -> None:
fn = js.find("function renderStoredMessage")
assert fn != -1
body = js[fn : js.find("function restoreConversation", fn)]
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
assert "closeToolCalls(wrap)" in body
assert (
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)")
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
< body.find("closeToolCalls(wrap)")
), "the fold sits AFTER the tool-line restore loop"