phase: 146_search_tool_scope
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:
@@ -1,34 +0,0 @@
|
||||
# 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):** `ChatToolEvent` gains `path: str | None = None` — ADDITIVE-OMISSION: a `model_serializer(mode="wrap")` drops the key when None (the `DocContent` `image_url` precedent), so every frame that is not a scoped grep serializes byte-identical to pre-phase. The frame builder sets it ONLY for `piece.name == "grep"` when `piece.arguments.get("path")` is a string (a non-string `path` is 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):** `appendToolLine` gains a fifth `scope` parameter (default null). The grep branch appends, ONLY when `scope` is a non-empty string, a sibling `<span>` AFTER the pattern `<code>`: text node ` in ` + 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 reads `ev.path` (string guard), persists it on the `toolRecord` ONLY when non-null (additive JSONB key — a bare-grep record keeps the exact pre-phase shape), and extends the grep status label with ` in ${scope}`. The restore path reads the saved `t.path` through the same helper — a record saved before this phase (no key) renders byte-identically.
|
||||
- **The E2E (task 02):** `mock_llm.py` gains ONE scripted question branch: a scoped `grep` (`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
|
||||
1. `01_tool_frame_scope.md` — the additive `path` field on `ChatToolEvent` + the frame builder + the serialization matrix.
|
||||
2. `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 (`path` key 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 <pattern> in <source/path>" for a scoped grep only
|
||||
- [ ] a saved scoped-grep turn persists `path` on 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-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] 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)
|
||||
@@ -1,27 +0,0 @@
|
||||
# Task 01 — the `tool` frame carries the grep's scope: additive `path` on `ChatToolEvent`
|
||||
|
||||
**Phase:** `146_search_tool_scope` · **Source:** `TODO.md:3` — "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
|
||||
|
||||
## Objective
|
||||
The SSE `tool` frame for a SCOPED grep (a re-search of one document — `grep(pattern, path)`) carries the document's combined `source/path` identity as an additive `path` field, so the UI can show WHERE the search runs. Every frame that is not a scoped grep serializes byte-identical to pre-phase.
|
||||
|
||||
## Work
|
||||
1. `app/schemas.py` — `ChatToolEvent` (L182): add `path: str | None = None` with a `@model_serializer(mode="wrap")` that pops `path` when it is None (the `DocContent._serialize` `image_url`-omission precedent — the key is ABSENT from the wire, never `null`). Update the class docstring: the A15 extension line gains the phase-146 note (a scoped grep's optional `path` — the re-searched document's combined `source/path` — is the ONLY case the field is present).
|
||||
2. `app/api/chat.py` — the `ToolCallPiece` branch (~L915–947, where `argument` is extracted): compute, for `piece.name == "grep"` ONLY, `scope = piece.arguments.get("path")` and coerce with the same string guard as `argument` (`scope if isinstance(scope, str) else None` — a non-string `path` is a model error the agent already refuses; the guard just yields None → omitted). Pass `path=scope` to the `ChatToolEvent(...)` constructor. No other event type, tool name, or field is touched — read/ls frames and every non-scoped grep keep the exact pre-phase wire bytes (the wrap serializer does the omission).
|
||||
3. `tests/unit/` — the `ChatToolEvent` serialization home (the existing SSE-frame unit file — `tests/unit/test_chat*.py`; if no frame serializer test exists yet, create one following the house layout): the matrix —
|
||||
- scoped grep: `name="grep"`, `argument="pattern"`, `path="srcs/doc.md"` → the dumped dict has `type`, `name`, `argument`, AND `path` (exact values);
|
||||
- bare grep: `path=None` → the dumped dict has NO `path` key (assert `"path" not in data` — not `null`);
|
||||
- `read` (with and without offset/limit) and `ls` (with and without argument) → NO `path` key, byte-identical to pre-phase (assert the full dumped dict equality against the literal pre-phase shape);
|
||||
- grep with a NON-string `path` (the model-error shape, e.g. a list) → the builder yields `path=None` → omitted (this case is pinned at the builder level in the step-4 test).
|
||||
4. `tests/unit/` — the frame-builder test (the chat-endpoint unit home that already drives `ToolCallPiece` handling, if one exists — otherwise a focused unit test constructing the piece and invoking the same extraction): grep piece with `arguments={"pattern": "p", "path": "s/d.md"}` → event carries both; grep piece with `arguments={"pattern": "p"}` → no scope; grep piece with `arguments={"pattern": "p", "path": 42}` → scope None (omitted); read/ls pieces → scope never read from their arguments.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: the two matrices above; every pre-existing frame pin passes UNCHANGED (byte-identity of non-scoped frames is the point).
|
||||
- Coverage: **>90%** on this task's new/modified code (`app/`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a scoped grep's `tool` frame carries `path` = the model's `path` string; a bare grep's frame has no `path` key at all
|
||||
- [ ] read/ls/null-argument frames dump to the exact pre-phase dict (equality pins)
|
||||
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] no other SSE event type or agent-tool surface is touched
|
||||
@@ -1,29 +0,0 @@
|
||||
# Task 02 — the tool line + status label show the search scope, live and restored; the dedicated E2E
|
||||
|
||||
**Phase:** `146_search_tool_scope` · **Source:** `TODO.md:3` — "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
|
||||
|
||||
## Objective
|
||||
A scoped grep renders `🔎 Searching for <pattern> in <source/path>` in the tool line and "… is searching for <pattern> in <source/path>" in the status label — live, persisted on the saved turn (additively), and re-rendered on restore. Every bare-grep rendering is byte-identical to pre-phase.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/app.js` — `appendToolLine` (~L1122): add a fifth parameter `scope = null`. In the grep branch (the `(name === "grep" || name === "search_documents") && argument` arm), AFTER the pattern `<code>` child, append ONLY when `typeof scope === "string" && scope` — the phase-139 read-range-suffix pattern: a `<span>` (class `grep-scope`) containing a text node ` in ` and a second `<code>` whose `textContent = scope` (the scope is data, never markup). The existing `🔎 Searching for ` template literal and the pattern `<code>` are untouched (the frontend emoji guard strips precisely those glyphs in this file — NO new emoji is introduced). A null/empty scope → no span → the line is byte-identical to pre-phase.
|
||||
2. `frontend/assets/app.js` — the live tool-frame handler (~L3178): read `const framePath = typeof ev.path === "string" && ev.path ? ev.path : null;` (the `ev.offset`/`ev.limit` guard style); add it to the `toolRecord` ONLY when non-null (`if (framePath !== null) toolRecord.path = framePath;` — the additive `bor.chat.v1` JSONB convention: a bare-grep record keeps the exact pre-phase `{name, argument}` shape, old records restore byte-identically, no migration); pass `framePath` as `appendToolLine`'s fifth argument; extend the grep status label — `(name === "grep" || name === "search_documents") && argument` → `` `${brand()} is searching for ${argument}${framePath ? ` in ${framePath}` : ""}` `` (bare grep → byte-identical label).
|
||||
3. `frontend/assets/app.js` — the restore path (~L2064–2086): inside the `m.tools` loop, read the saved scope with the same guard (`const tPath = typeof t.path === "string" && t.path ? t.path : null;`) and pass it to `appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)` — a record saved before this phase (no `path` key) renders byte-identically (the phase-95 truncation-note precedent: it keys on the `<code>` argument, not the scope).
|
||||
4. `tests/e2e/mock_llm.py` — one new scripted-question branch (the house scripted-turn idiom, modeled on the phase-70/72 `grep` branches): a sentinel question → the model streams ONLY a `tool_calls` delta calling `grep` with `{"pattern": <sentinel>, "path": "<fixture source>/<fixture doc>"}` (id `call_0`) → the follow-up round (tool result present) streams the terminal answer. The fixture source/doc pair must exist in the session app's indexed KB (the same fixture the `grep` scripted branches already use — reuse its source/path so the agent's real grep finds matches; the exact path string the UI renders is the model's, so use the fixture's real combined identity). The EXISTING bare-grep scripted turns are untouched (they are the byte-identity pins).
|
||||
5. `tests/e2e/test_search_tool_scope.py` — the dedicated E2E (one Playwright file, run in isolation, mock-only — the house module-app pattern if the scripted mock lives in the session app, else the session app directly):
|
||||
- **scoped line:** ask the scripted question → after the tool frame, the `.tool-calls` list holds one `.tool-call` whose text is `🔎 Searching for <pattern> in <source/path>` — assert the TWO `<code>` spans (pattern, then scope) and the ` in ` text node between them (textContent structure, not a regex over the whole line — the pin style of the phase-139 range-suffix assertions);
|
||||
- **status label:** while the turn is live (before the delta frames), the typing indicator/status carries `… is searching for <pattern> in <source/path>`;
|
||||
- **bare-grep byte-identity:** an existing bare-grep scripted question renders exactly the pre-phase line `🔎 Searching for <pattern>` (ONE `<code>` span, no `.grep-scope` span) and the pre-phase status label;
|
||||
- **restore:** save the scoped turn (the save affordance), reload the chat, the restored turn's tool line re-renders the scope through the same structure (folded disclosure — the phase-117 shape is unchanged).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E: the assertions above; every pre-existing tool-line pin (phase-70 labels, phase-87 clock, phase-117 disclosure, phase-139 range suffix) passes UNCHANGED — the byte-identity of bare greps is the point.
|
||||
- Coverage: n/a (frontend task — the E2E is the gate; `app/` coverage stays >90% from task 01).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] a scoped grep's tool line shows the scope in a second `<code>` span after the pattern; the status label carries ` in <scope>`; both live and on restore
|
||||
- [ ] a bare grep renders byte-identical to pre-phase in every surface (live line, status, saved record shape, restored line)
|
||||
- [ ] the saved scoped record carries `path` additively; pre-phase saved records (no key) restore byte-identically
|
||||
- [ ] `uv run pytest` green (all pre-existing suites); `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
|
||||
- [ ] no new emoji anywhere in `app.js` (the frontend emoji guard stays green)
|
||||
Reference in New Issue
Block a user