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
+55 -5
View File
@@ -1119,8 +1119,21 @@ function closeToolCalls(wrap) {
* (phase 14) carry the old names, so both generations render — no
* migration. The content marks (the read/search/list glyphs) stay the
* exact tool-line template literals — the frontend emoji guard strips
* precisely those in this file. */
function appendToolLine(wrap, name, argument, offset = null, limit = null) {
* precisely those in this file.
*
* Phase 146 (task 02): the grep arm gains the additive `scope` param
* (default null — the frame's `path` field / the saved record's
* `path`): a SCOPED grep's line shows WHERE the search runs — the
* pinned Searching label + the pattern <code>, then " in " + the scope
* in a second <code> — so repeated patterns with different scopes read
* as distinct searches (the "looks like the LLM is repeating itself"
* confusion, TODO.md L3). The scope is a
* .grep-scope span SIBLING after the pattern <code> (the phase-139
* read-range-suffix precedent), appended ONLY when it is a non-empty
* string: a bare grep's line stays byte-identical to pre-phase (the
* phase-70/87/117 pins and the frontend emoji guard stay green — no
* new emoji). */
function appendToolLine(wrap, name, argument, offset = null, limit = null, scope = null) {
const body = wrap?.querySelector?.(".msg-body");
if (!body) return;
// Phase 117 (D2): the lines ride in a native <details> disclosure —
@@ -1172,6 +1185,22 @@ function appendToolLine(wrap, name, argument, offset = null, limit = null) {
const code = document.createElement("code");
code.textContent = argument; // the pattern is data, never markup
line.appendChild(code);
// Phase 146 (task 02): the SCOPE of a scoped grep — the re-searched
// document's combined source/path — appended as a SIBLING after the
// pattern <code> (the phase-139 read-range-suffix precedent): a
// .grep-scope span holding the " in " text node + a second <code>,
// ONLY when the scope is a non-empty string. A bare grep's line
// stays byte-identical to pre-phase (no span, no extra bytes);
// the scope is data, never markup (no new emoji).
if (typeof scope === "string" && scope) {
const scopeSpan = document.createElement("span");
scopeSpan.className = "grep-scope";
scopeSpan.appendChild(document.createTextNode(" in "));
const scopeCode = document.createElement("code");
scopeCode.textContent = scope; // the scope is data, never markup
scopeSpan.appendChild(scopeCode);
line.appendChild(scopeSpan);
}
} else if (name === "ls" && argument) {
line.textContent = "🔎 Listing documents in ";
const code = document.createElement("code");
@@ -2087,7 +2116,14 @@ function renderStoredMessage(m) {
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
const tLimit =
Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null;
appendToolLine(wrap, t.name, arg, tOffset, tLimit);
// Phase 146 (task 02): a saved SCOPED grep (the live toolAcc
// stamp — the additive `path` key on the bor.chat.v1 record)
// re-renders the SAME scope through the SAME helper: the same
// string guard → a record saved before the phase (no `path`
// key) renders byte-identically (the phase-95 truncation note
// is untouched — it keys on the <code> argument, not the scope).
const tPath = typeof t.path === "string" && t.path ? t.path : null;
appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath);
if (t.truncated && arg) {
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
}
@@ -3183,9 +3219,23 @@ async function runTurn(text, { reask = false, image = null } = {}) {
Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null;
const frameLimit =
Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null;
// Phase 146 (task 02): the SCOPE of a scoped grep — the
// frame's additive `path` field (ABSENT on every non-scoped
// frame, never null — the ChatToolEvent serializer drops it
// when None). The SAME string guard as `argument` → null when
// absent / malformed; the record gains `path` ONLY when
// non-null (a bare grep keeps the exact pre-phase
// {name, argument} shape — the bor.chat.v1 save JSONB is
// additive, old records restore byte-identically, no
// migration), and the line + the status label show WHERE the
// search runs (the "looks like the LLM is repeating itself"
// confusion, TODO.md L3).
const framePath =
typeof ev.path === "string" && ev.path ? ev.path : null;
const toolRecord = { name, argument };
if (frameOffset !== null) toolRecord.offset = frameOffset;
if (frameLimit !== null) toolRecord.limit = frameLimit;
if (framePath !== null) toolRecord.path = framePath;
toolAcc.push(toolRecord);
clearTurnTimeout(); // the stream is alive — a frame arrived
if (!wrap) wrap = addMessage("brain", "");
@@ -3201,7 +3251,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
(name === "read" || name === "read_document") && argument
? `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`
: (name === "grep" || name === "search_documents") && argument
? `${brand()} is searching for ${argument}`
? `${brand()} is searching for ${argument}${framePath ? ` in ${framePath}` : ""}`
: name === "ls" && argument
? `${brand()} is listing documents in ${argument}`
: `${brand()} is listing documents`;
@@ -3211,7 +3261,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
.querySelector("#typing-indicator .bubble")
?.setAttribute("aria-label", toolStatus);
}
appendToolLine(wrap, name, argument, frameOffset, frameLimit);
appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath);
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
// baseline resets per line, so each line counts its OWN
// frameless silence. This live branch is the ONLY arm call site