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`.
6.1 KiB
Phase 146 — the search tool line shows WHERE the LLM is searching: a scoped grep carries its document
Source: TODO.md L3 — "The "search" tool in the UI doesn't show where the LLM is searching. This results in, what looks like, multiple search tool calls with the same value, like the LLM is repeating itself."
Story: n/a (TODO-derived phase, Protocol B append).
Context: app/rag/agent.py — the grep tool definition (~L395–441: required pattern + optional path — "Rarely needed — only for re-searching one document you already know: that document's combined source/path identity"); app/api/chat.py — the ToolCallPiece → SSE tool frame builder (~L909–947: argument = piece.arguments.get("pattern" if piece.name == "grep" else "path") — a scoped grep's path argument is DROPPED today, so every search line renders the same pattern); app/schemas.py — ChatToolEvent (L182: {name, argument, offset?, limit?} — the additive-omission serializer precedent is DocContent._serialize dropping image_url when None); frontend/assets/app.js — appendToolLine (~L1122: the grep branch renders 🔎 Searching for <code>pattern</code> — no scope, byte-identical for bare and scoped calls), the live tool-frame handler (~L3178–3230: ev.offset/ev.limit additive wire fields + the toolRecord stamp — the exact pattern this phase extends with ev.path), the restore path (~L2064–2086: re-renders saved {name, argument, offset?, limit?} records through the same helper); tests/e2e/mock_llm.py — the scripted-turn idiom (the phase-70/72 grep scripted branches — the house way of driving a model tool call deterministically).
Locked (owner delegation 2026-09-30, chat — "pick the best choices"): the fix is the missing SCOPE: the tool frame additively carries the grep's optional path (the re-searched document's combined source/path), and the UI renders it — 🔎 Searching for <pattern> in <source/path> — so a whole-KB search and a re-search of one document are visibly different (the "repeating itself" confusion is gone). Bare greps render byte-identical to pre-phase. The scope also rides the status label ("… is searching for X in Y") and persists additively on the saved turn's tool record (bor.chat.v1 JSONB — old records restore byte-identically, no migration).
Objective
A grep that re-searches a specific document shows THAT document in the chat's tool line and status label, so repeated patterns with different scopes read as distinct searches; every bare-grep rendering (live, restored, persisted) is byte-identical to pre-phase.
Dependencies
145_git_source_links(todo) — pipeline order only; no shared code (this phase touches the tool-frame wire + the chat tool-line renderer; 145 touches read results + the doc viewer meta row).
Design (shared by all tasks — the executor reads this, not the chat)
- The wire (task 01):
ChatToolEventgainspath: str | None = None— ADDITIVE-OMISSION: amodel_serializer(mode="wrap")drops the key when None (theDocContentimage_urlprecedent), so every frame that is not a scoped grep serializes byte-identical to pre-phase. The frame builder sets it ONLY forpiece.name == "grep"whenpiece.arguments.get("path")is a string (a non-stringpathis a model error the backend already refuses — the guard just yields None → omitted). Legacy tool names never appear on the live wire (the server only emits current names), so no legacy handling. - The UI (task 02):
appendToolLinegains a fifthscopeparameter (default null). The grep branch appends, ONLY whenscopeis a non-empty string, a sibling<span>AFTER the pattern<code>: text nodein+ a second<code>(the scope is data, never markup; NO new emoji — the frontend emoji guard strips precisely the existing template literals, which stay untouched — the phase-139 read-range-suffix span-append precedent). The live handler readsev.path(string guard), persists it on thetoolRecordONLY when non-null (additive JSONB key — a bare-grep record keeps the exact pre-phase shape), and extends the grep status label within ${scope}. The restore path reads the savedt.paththrough the same helper — a record saved before this phase (no key) renders byte-identically. - The E2E (task 02):
mock_llm.pygains ONE scripted question branch: a scopedgrep(pattern+path) followed by the terminal answer — the house scripted-turn idiom; the bare-grep scripted turns from phases 70/72 stay as the byte-identity pins.
Tasks
01_tool_frame_scope.md— the additivepathfield onChatToolEvent+ the frame builder + the serialization matrix.02_ui_scope_render_e2e.md— the tool line + status label + persisted record + restore path + the dedicated E2E.
Testing & Quality
- Unit: the frame-serialization matrix (scoped grep / bare grep / read / ls / null-argument — non-scoped frames byte-identical pins).
- E2E: one dedicated Playwright file, run in isolation, mock-only.
- Coverage: >90% on new/modified code (
app/).
Completion Criteria
- a scoped grep streams
{"type": "tool", "name": "grep", "argument": <pattern>, "path": <source/path>}; a bare grep's frame is byte-identical to pre-phase (pathkey ABSENT, not null); read/ls frames unchanged - the chat tool line for a scoped grep reads
🔎 Searching for <pattern> in <source/path>(two<code>spans, no new emoji); the bare-grep line is byte-identical (the phase-70/87/117 pins pass unchanged) - the status label says "… is searching for in <source/path>" for a scoped grep only
- a saved scoped-grep turn persists
pathon the tool record and re-renders the scope on restore; pre-phase saved records restore byte-identically uv run pytest --cov=app --cov-report=term-missinggreen,app/coverage >90%;uv run pytest tests/e2e/test_search_tool_scope.py -v --no-covgreen in isolation;uv run ruff check . && uv run pyrightclean- no behavior change in completed phases (the read/ls tool lines, the disclosure count, the saved-turn JSON shape for non-scoped records, the done frame)