All verification complete. Every gate is green and the docs check out. Final report:
## Phase 158 final verification pass — COMPLETE
**Verified (all tasks pre-completed; this pass re-ran the full gate):**
- Unit + integration: `uv run pytest --cov=app --cov-report=term-missing` → **3618 passed, 99% coverage on `app/`** (>90% gate)
- Phase E2E in isolation: `uv run pytest tests/e2e/test_thinking_timeline.py -v --no-cov` → **6/6 passed** (order pin, fold-at-rest, live-open, restore+`segments` array, shared page, no-thinking single disclosure)
- Regression E2E, each in isolation: `test_thinking_display` 5✓, `test_tool_call_compact` 4✓, `test_turn_progress_loader` 3✓ (per-run pin updated), `test_big_read_progress` 4✓, `test_agent_document_tools` 4✓, `test_share_chat` 4✓, `test_chat_persistence` 4✓, `test_smoke` 3✓
- Lint/types: `uv run ruff check . && uv run pyright` → **clean (0 errors)**
**Completion criteria:** all 9 met — timeline E2E pins ✓, restore/shared `segments` ✓, no-thinking single-disclosure + the three UNCHANGED regression suites green ✓, pre-phase byte-identical round-trip (integration pin: key absent, never null; >50 / >32 000 / empty text / unknown kind all 422) ✓, full suite + coverage + E2E + lint ✓, `git diff --stat` footprint limited to `app/schemas.py`, `frontend/assets/{app,shared}.js`, `tests/`, README, phase files (a pre-existing owner `TODO.md` edit is out of scope, not staged by the phase commit) ✓, D15→per-run supersession recorded in the updated pin ✓. (Commit + phase move are the harness's job; task dirs already in `complete/`.)
**Documentation:** README.md — Thinking section correctly describes the phase-158 timeline; config reference matches `.env.example` exactly; page table matches registered routes; no stale single-block wording. AGENTS.md — rules and quick-reference commands all accurate. **Verified consistent — no changes needed.**
**Decisions/deviations:** none. `test_tool_call_compact.py` carries a one-line docstring-only update (referenced the removed `closeToolCalls`); its test logic is byte-identical to the phase-117 contract and green.
**Next pending phase:** none — `todo/` contains only 158.
549 lines
22 KiB
Python
549 lines
22 KiB
Python
"""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 takes the CONTAINER as its first param (phase
|
||
158, D1 — the caller passes the run's disclosure; the
|
||
single-container derivation is gone) + 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(container, name, argument, offset = null, "
|
||
"limit = null, scope = null)" in js
|
||
), ("the signature carries the container + 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 (
|
||
"const line = appendToolLine(" in branch
|
||
and "armToolLineClock(wrap, line)" 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(restoreDisc, t.name, arg, tOffset, tLimit, tPath)" in body
|
||
), ("the stored scope rides the SAME helper (the caller passes the "
|
||
"run's disclosure — phase 158)")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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 makeDisclosure() {
|
||
const container = document.createElement("details");
|
||
container.className = "tool-calls-disclosure";
|
||
const summary = document.createElement("summary");
|
||
summary.className = "tool-calls-summary";
|
||
container.appendChild(summary);
|
||
const listEl = document.createElement("div");
|
||
listEl.className = "tool-calls";
|
||
container.appendChild(listEl);
|
||
const wrap = makeWrap();
|
||
wrap.querySelector(".msg-body").appendChild(container);
|
||
return { wrap, container };
|
||
}
|
||
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 a0 = makeDisclosure();\n"
|
||
+ 'appendToolLine(a0.container, "grep", PATTERN);\n'
|
||
+ 'const bare = describe(a0.wrap.querySelector(".tool-call"));\n'
|
||
# bare grep, legacy name (no scope)
|
||
+ "const a1 = makeDisclosure();\n"
|
||
+ 'appendToolLine(a1.container, "search_documents", PATTERN);\n'
|
||
+ 'const legacy = describe(a1.wrap.querySelector(".tool-call"));\n'
|
||
# guarded fallbacks: empty string + non-string scope
|
||
+ "const a2 = makeDisclosure();\n"
|
||
+ 'appendToolLine(a2.container, "grep", PATTERN, null, null, "");\n'
|
||
+ 'const emptyScope = describe(a2.wrap.querySelector(".tool-call"));\n'
|
||
+ "const a3 = makeDisclosure();\n"
|
||
+ 'appendToolLine(a3.container, "grep", PATTERN, null, null, 42);\n'
|
||
+ 'const badScope = describe(a3.wrap.querySelector(".tool-call"));\n'
|
||
# the scoped grep
|
||
+ "const a4 = makeDisclosure();\n"
|
||
+ 'appendToolLine(a4.container, "grep", PATTERN, null, null, SCOPE);\n'
|
||
+ 'const scoped = describe(a4.wrap.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 a0 = makeDisclosure();\n"
|
||
+ 'appendToolLine(a0.container, "read", "s/d.md", null, null, "x");\n'
|
||
+ 'const readLine = describe(a0.wrap.querySelector(".tool-call"));\n'
|
||
+ "const a1 = makeDisclosure();\n"
|
||
+ 'appendToolLine(a1.container, "ls", "s", null, null, "x");\n'
|
||
+ 'const lsLine = describe(a1.wrap.querySelector(".tool-call"));\n'
|
||
+ "const a2 = makeDisclosure();\n"
|
||
+ 'appendToolLine(a2.container, "ls", null, null, null, "x");\n'
|
||
+ 'const plainLine = describe(a2.wrap.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"]
|