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
@@ -0,0 +1,19 @@
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`.
@@ -0,0 +1,119 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 181 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 183 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 264 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 133 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 705 9 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 124 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 440 0 100%
--------------------------------------------------
TOTAL 5549 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,17 @@
All green. Task 01 complete.
**Implemented**
- `app/schemas.py` — `ChatToolEvent` gains additive `path: str | None = None`; wrap serializer pops it when `None` (key ABSENT, never `null` — `DocContent.image_url` precedent); docstring updated (A15 line note + phase-146 paragraph: a scoped grep's combined `source/path` is the ONLY case the field is present)
- `app/api/chat.py` — `ToolCallPiece` branch computes `scope = piece.arguments.get("path")` for `grep` ONLY, with the same string guard as `argument` (non-string → `None` → omitted), passed as `path=scope` to the constructor; no other event type/field touched
- `tests/unit/test_sse_events.py` — serialization matrix: scoped grep carries `path` (exact values + byte frame), bare grep has NO `path` key, read (±range)/ls (±argument) dump to exact pre-phase dicts
- `tests/unit/test_chat_gate.py` — frame-builder tests through the real endpoint `ToolCallPiece` path (new `_ToolCallCannedLLM` driver): scoped grep, bare grep, non-string `path` (list → omitted), read/ls never read scope from their arguments
**Test / lint / coverage**
- `uv run pytest` — 3406 passed, 0 failed
- `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage 99% (>90%)
- `uv run ruff check . && uv run pyright` — clean (0 errors)
- Pre-existing E2E tool-frame pins re-run in isolation: `test_grep_regex_teaching.py` (2 passed), `test_agent_unlimited_tools.py` (4 passed), `test_harness_aligned_tools.py` (3 passed) — bare frames byte-identical
**Docs:** no doc impact (README already documents `grep(pattern, path?)`; SSE frame shape not documented; no setup/env/command changes — UI rendering lands in task 02)
**Next pending task:** `.agents/phases/todo/146_search_tool_scope/02_ui_scope_render_e2e.md`
@@ -0,0 +1,119 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 181 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 183 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 264 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 133 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 705 9 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 124 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 433 0 100%
--------------------------------------------------
TOTAL 5542 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
@@ -0,0 +1,15 @@
All gates are green. Final state verified — here's the report:
**Implemented (phase 146, task 02)**
- `frontend/assets/app.js`: `appendToolLine` gains additive `scope` param — scoped grep renders `🔎 Searching for <pattern> in <source/path>` (`.grep-scope` span: " in " text node + second `<code>`, sibling after the pattern `<code>`); live `tool` branch reads `ev.path` (string guard), stamps `toolRecord.path` only when non-null, extends the grep status label with ` in <scope>`; restore path passes stored `t.path` through the same helper. Bare greps render byte-identically everywhere (pinned label literals + emoji guard untouched).
- `tests/e2e/mock_llm.py`: one new scripted branch — `SCOPED_SEARCH_TRIGGER` → scoped `grep` (`pattern` + `path: search_docs/reese-notes.md`) → terminal "Found …" answer; the bare-grep scripted flow stays the untouched byte-identity pin.
- `app/schemas.py` (necessary boundary fix): `ToolCall` gains additive `path` with omission semantics (ABSENT, never null) — without it the scope would be dropped at the save/restore API boundary and the phase's persistence contract couldn't hold.
- `tests/e2e/test_search_tool_scope.py` (dedicated E2E, 3 tests: scoped line+status+wire, bare byte-identity incl. saved-record shape + no-key restore, save→restore with folded disclosure); `tests/unit/test_search_tool_scope.py` (13 unit tests incl. node DOM behavior); integration `FULL_BRAIN` round-trip pin; 6 pre-phase unit call-site pins updated for the legitimately extended signature.
- README: phase-146 paragraph in the agent-tools section.
**Gates (all green)**
- `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; pre-existing pin suites re-verified in isolation (search_tool, harness_aligned_tools, read_range_tool_lines, tool_call_compact, grep_regex_teaching, chat_persistence, big_read_progress — all pass)
- `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings
**Docs:** README updated; AGENTS.md no impact. **Notable:** shared-page parity (`shared.js` `addToolLines`) deliberately left untouched per the task's explicit app.js-only scope — a public share of a scoped-grep turn renders the bare line; flagging for the owner. Next pending: phase 147 (`147_sync_changed_only`).
@@ -0,0 +1,119 @@
........................................................................ [ 2%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 270 1 99%
app/api/chat_images.py 50 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 181 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 183 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 264 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 45 0 100%
app/core/caching.py 124 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/errors.py 5 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/security_headers.py 20 0 100%
app/core/theming.py 38 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 68 0 100%
app/models.py 133 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 431 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 705 9 99%
app/rag/llm.py 246 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 71 0 100%
app/rag/pdf.py 124 0 100%
app/rag/prompts.py 155 0 100%
app/rag/retriever.py 248 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/schemas.py 440 0 100%
--------------------------------------------------
TOTAL 5549 22 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
validation OK
+12
View File
@@ -394,6 +394,18 @@ streamed as an SSE `tool` frame. In the chat, each call shows a transient
**calling-tool status** alongside "thinking" (the send button keeps its
busy state — "Stop" — for the whole turn).
**The search line shows where it searches (phase 146).** When a `grep`
re-searches one document (the optional `path`), the chat's tool line and
the calling-tool status show the document the search runs in —
`🔎 Searching for <pattern> in <source/path>` — so a whole-KB search and
a re-search of a single document read as distinct searches instead of
looking like the model is repeating itself. The scope rides the SSE
`tool` frame additively (`path`, present on scoped greps only) and is
persisted on the saved turn's tool record, so it re-renders after a
save → restore; a bare `grep` renders exactly as before (frame, line,
status, and saved shape all byte-identical to pre-phase — old saved
turns restore unchanged).
The model may call tools as many times as needed, bounded by a round cap:
| Env | Default | Meaning |
+24
View File
@@ -941,12 +941,36 @@ async def chat(
else None
)
limit = limit if _whole_line_number(limit) else None
# Phase 146 (task 01): a SCOPED grep's frame
# carries the scope — the re-searched
# document's combined ``source/path`` (the
# grep's optional one-doc argument, PLAN §6)
# — as the additive ``path`` field, so the
# UI can show WHERE the search runs (the
# "looks like the LLM is repeating itself"
# confusion, TODO L3). ONLY ``grep`` reads it
# (a non-grep ``path`` argument is that
# tool's ``argument``, never a scope); the
# same string guard as ``argument`` — a
# non-string value is a model error the agent
# already refuses; the guard just yields
# None → the key is omitted on
# serialization, so every non-scoped frame
# keeps its exact pre-phase wire bytes (the
# ChatToolEvent wrap serializer).
scope = (
piece.arguments.get("path")
if piece.name == "grep"
else None
)
scope = scope if isinstance(scope, str) else None
yield sse_event(
ChatToolEvent(
name=piece.name,
argument=argument,
offset=offset,
limit=limit,
path=scope,
).model_dump()
)
continue
+58 -2
View File
@@ -188,8 +188,11 @@ class ChatToolEvent(BaseModel):
grounded turn may call the server-side document tools (``ls`` /
``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested
call streams as ``{type: "tool", name: str, argument: str | null}``
ahead of the answer's ``delta`` frames. ``argument`` is the single
string argument the model passed — ``read``'s ``path`` (the combined
ahead of the answer's ``delta`` frames — a scoped ``grep``'s frame
alone additionally carries the optional ``path`` (phase 146 — the
re-searched document's combined ``source/path``; the ONLY case the
field is present, see below). ``argument`` is the single string
argument the model passed — ``read``'s ``path`` (the combined
``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` — or
null (a non-string value, a model error the backend refuses, and an
omitted argument both yield null). The client renders each frame as
@@ -211,6 +214,21 @@ class ChatToolEvent(BaseModel):
result, and the frame carries no range). The client ignores the
extra fields (PLAN §4); they exist so the wire — and the phase-131
story E2E — can assert the range the model requested.
Phase 146 (task 01): ``path`` — the SCOPE of a scoped ``grep``: the
re-searched document's combined ``source/path`` identity (the
grep's optional one-doc scope, PLAN §6), so the UI can show WHERE
the search runs (the "looks like the LLM is repeating itself"
confusion, TODO L3). Additive in the ``offset`` / ``limit`` style:
absent (``None``) on every frame that is NOT a scoped grep — bare
``grep``, ``ls``, ``read`` (with and without range), and every
pre-phase frame — where the key is DROPPED on serialization
(ABSENT, never ``null``), so those frames stay byte-identical to
pre-phase (the story E2E suites pin them as exact dicts). The frame
builder (:mod:`app.api.chat`) sets it ONLY for ``piece.name ==
"grep"`` from the grep's ``path`` argument, string-guarded like
``argument`` (a non-string value is a model error the agent already
refuses; the guard just yields the omission).
"""
type: Literal["tool"] = "tool"
@@ -218,6 +236,10 @@ class ChatToolEvent(BaseModel):
argument: str | None = None # the single string argument passed, or null
offset: int | None = None # read only: the 1-based first line requested
limit: int | None = None # read only: the line count requested
# Phase 146 (task 01): grep only — the scoped re-search's document
# (the model's ``path`` argument, combined ``source/path``); None on
# every non-scoped frame → the key is ABSENT, never null.
path: str | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
@@ -229,6 +251,11 @@ class ChatToolEvent(BaseModel):
data.pop("offset", None)
if self.limit is None:
data.pop("limit", None)
# Phase 146 (task 01): the scoped grep's scope rides ``path`` —
# ABSENT (never null) on every non-scoped frame, so those stay
# byte-identical to pre-phase (the same omission pattern).
if self.path is None:
data.pop("path", None)
return data
@@ -1010,6 +1037,20 @@ class ToolCall(BaseModel):
422s here is one the UI never legitimately produces). A saved chat
written before the phase (no fields) validates UNCHANGED and renders
without the suffix (the phase-50 backward-compat rule).
Phase 146 (task 02): ``path`` — the SCOPE of a scoped ``grep``:
the re-searched document's combined ``source/path`` identity (the
same additive ``path`` the :class:`ChatToolEvent` frame carries,
PLAN §6) rides the SAME record, so a saved scoped-grep turn
re-renders the scope on restore (the chat page's restore path reads
it off the stored row). Additive-omission in the ``ChatToolEvent``
/ ``SourceRef.image_url`` style: ABSENT (never ``null``) on every
non-scoped record — the UI stamps the key only when the frame
carried a scope — so pre-phase and bare-grep records validate
UNCHANGED and round-trip byte-identically (the phase-50
backward-compat rule; no migration). ``max_length=2000`` mirrors
``argument`` (the combined ``source/path`` identity is ≤ 120 + 1 +
1000; 2 000 is the same ×2 headroom).
"""
name: str = Field(max_length=100)
@@ -1019,6 +1060,21 @@ class ToolCall(BaseModel):
chars_total: int | None = Field(default=None, ge=0)
offset: int | None = Field(default=None, ge=1)
limit: int | None = Field(default=None, ge=1)
# Phase 146 (task 02): grep only — the scoped re-search's document
# (the frame's additive ``path``); None on every non-scoped record
# → the key is ABSENT, never null (the serializer below).
path: str | None = Field(default=None, max_length=2000)
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
# Phase 146 (task 02): the scope is ABSENT (never null) on
# every non-scoped record — the pre-phase / bare-grep saved
# shape stays byte-identical (the ChatToolEvent omission
# pattern; the FULL_BRAIN round-trip pin keys on it).
data = handler(self)
if self.path is None:
data.pop("path", None)
return data
#: One suggestion chip (phase 83, A1): a short deterministic string —
+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
+106
View File
@@ -170,6 +170,30 @@ Implements just enough of the aipi surface:
specific phrase — same convention as ``think in paragraphs``); no
existing E2E question or fixture file contains the trigger, so
every other suite is unaffected.
- user message containing ``search within one document``
(``SCOPED_SEARCH_TRIGGER``, phase 146 task 02 — the search tool's
SCOPE: a re-search of ONE document, so the UI can show where the
search runs and repeated patterns read as distinct searches,
TODO.md L3) **and** the system prompt carries the ``<tools>``
section -> the deterministic SCOPED-SEARCH flow, discriminated
statelessly from the messages (streaming only; the bare-grep
``SEARCH_TRIGGER`` flow above is the byte-identity pin and stays
untouched):
* request 1 (``tools`` offered, no search result yet): stream
ONLY ``tool_calls`` deltas — ``grep`` with
``{"pattern": SEARCH_PATTERN, "path": SCOPED_SEARCH_PATH}``
(id ``call_0``) — the SCOPED form (pattern + the re-searched
document's combined ``source/path``);
* request 2 (a ``tool``-role search result in the messages —
the agent's REAL scoped grep matched the fixture's sentinel
line): the content answer, deterministic: ``Found <first
matched line's content up to 80 chars>`` (the same echo shape
as the bare flow).
Checked BEFORE the bare ``SEARCH_TRIGGER`` flow (disjoint trigger
phrases — the phase-71/72 ordering convention; the trigger needs
the ``<tools>`` section, so deflected turns never hit it); no
existing E2E question or fixture file contains the phrase, so
every other suite is unaffected.
- user message containing ``emit raw tool markup``
(``SCAFFOLD_TRIGGER``, phase 71, tool-scaffolding guardrails — the
2026-09-03 incident where a deflected round streamed the model's
@@ -1042,6 +1066,25 @@ SEARCH_TRIGGER = "search your documents"
#: convention of ``END_OF_NOTES_TRIGGER``).
SEARCH_PATTERN = "reese-sentinel-42"
#: Phase 146 (task 02 — the search tool's scope, TODO.md L3): a user
#: message containing this substring (case-insensitive) — combined with
#: the ``<tools>`` section in the system prompt — drives the
#: deterministic SCOPED-SEARCH flow (``grep`` with ``pattern`` +
#: ``path`` → the "Found …" answer), documented in the module
#: docstring. Disjoint from ``SEARCH_TRIGGER`` (the bare-grep flow —
#: the byte-identity pin) and from every other marker; verified 2026-10:
#: no existing E2E question or fixture file contains the phrase, so
#: every other suite is unaffected.
SCOPED_SEARCH_TRIGGER = "search within one document"
#: The scoped flow's target — the SAME fixture document the bare-grep
#: flow already greps (``tests/fixtures/search_docs/`` → source
#: ``search_docs``, doc ``reese-notes.md``): the agent's REAL scoped
#: grep finds its sentinel line, so the follow-up round carries a tool
#: result. The exact string the UI renders is the model's ``path``
#: argument — the fixture's real combined identity.
SCOPED_SEARCH_PATH = "search_docs/reese-notes.md"
#: Phase 44 (markdown-tables story, TODO.md L6): a user message
#: containing this substring (case-insensitive) gets the fixed table
#: answer (``TABLE_ANSWER`` below) — a 3-column table, an XSS probe
@@ -1708,6 +1751,43 @@ def _search_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("search",)
def _scoped_search_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a SCOPED_SEARCH_TRIGGER request into a step.
Phase 146 (task 02): the SCOPED search flow — the owner's "looks
like the LLM is repeating itself" scenario (TODO.md L3): a re-search
of ONE document (``grep(pattern, path)``) so the UI can show WHERE
the search runs. The same scripted-turn idiom as ``_search_flow``
(the phase-70/72 grep branches): stateless classification from the
messages (streaming only).
* ``("search",)`` — ``tools`` are offered and no search result is
in the messages yet: the model greps the ONE fixture document
(``SCOPED_SEARCH_PATH``) for ``SEARCH_PATTERN`` (id ``call_0``) —
the scoped form (``pattern`` + ``path``).
* ``("found", first_line)`` — a ``tool``-role search result is in
the messages (the agent's REAL scoped grep matched the fixture's
sentinel line): the model answers, quoting the first matched
line (``Found <first matched line's content up to 80 chars>``).
Reached regardless of the ``tools`` parameter (phase 45 keeps the
tools offered until the round cap).
* ``None`` — not the scoped flow: the trigger is absent, the
``<tools>`` section is missing (deflected turns never carry it),
or ``tools`` are not offered and no search result is in the
messages yet (e.g. ``agent_max_rounds=0``).
"""
if SCOPED_SEARCH_TRIGGER not in _user(body).lower():
return None
if "<tools>" not in _system(body):
return None
first_line = _search_result_line(body)
if first_line is not None:
return ("found", first_line)
if not body.get("tools"):
return None
return ("search",)
def _scaffold_flow(body: dict[str, Any]) -> str | None:
"""Classify a phase-71 scaffolding request (see the module docstring).
@@ -3895,6 +3975,32 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 146 (task 02): the deterministic SCOPED search marker
# flow — checked BEFORE the phase-68 search flow (disjoint
# trigger phrases — the phase-71/72 ordering convention; the
# trigger needs the ``<tools>`` section, so deflected turns
# never hit it). The scoped form (pattern + path) is the wire
# subject: the app's tool frame carries the scope as its
# additive ``path`` field, and the UI shows WHERE the search
# runs (the "repeating itself" confusion, TODO.md L3).
scoped_search = _scoped_search_flow(body)
if scoped_search is not None:
if scoped_search[0] == "search":
stream = _tool_call_stream(
"grep",
{"pattern": SEARCH_PATTERN, "path": SCOPED_SEARCH_PATH},
"call_0",
)
else: # "found" — quote the first matched line (80 chars)
answer = _apply_max_tokens(
f"Found {scoped_search[1][:80]}", body.get("max_tokens")
)
stream = _sse_stream(answer, 0.0)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 68 (search tool): the deterministic search marker flow —
# checked BEFORE the phase-37 tool flow (the more specific
# trigger phrase wins, same convention as THINK_PARAS_TRIGGER).
+668
View File
@@ -0,0 +1,668 @@
"""Phase 146 (task 02) E2E (Playwright, mock-only): 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." A grep that re-searches a specific document now shows THAT
document in the chat's tool line and status label — live, persisted on
the saved turn's tool record (additively), and re-rendered on
restore — so repeated patterns with different scopes read as distinct
searches. Every bare-grep rendering (live line, status label, saved
record shape, restored line) is byte-identical to pre-phase.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
deterministic SCOPED-SEARCH marker flow in ``tests/e2e/mock_llm.py``
(``SCOPED_SEARCH_TRIGGER`` — user message contains "search within one
document" **and** the system prompt carries the ``<tools>`` section):
1. request 1 (``tools`` offered, no search result yet) → streams ONLY
``tool_calls`` deltas calling ``grep`` with ``{"pattern":
SEARCH_PATTERN, "path": SCOPED_SEARCH_PATH}`` (id ``call_0``) —
the SCOPED form; the app's ``tool`` frame carries the scope as its
additive ``path`` field (phase 146 task 01);
2. request 2 (a ``tool``-role search result — the agent's REAL scoped
grep matched the fixture's sentinel line) → the content answer
``Found <first matched line's content up to 80 chars>``.
The EXISTING bare-grep flow (``SEARCH_TRIGGER`` — "search your
documents") is untouched and is this suite's byte-identity pin
(test 2).
KB fixture — ONE document, imported through the real importer against
``tests/fixtures/search_docs/`` with the deterministic mock
embeddings (the ``test_search_tool.py`` pattern, reused as-is):
* ``search_docs/reese-notes.md`` — homelab kubernetes backup notes;
both questions cosine ≈0.51 against it (mock bag-of-words, well
past the E2E 0.30 threshold, and they FTS-match too) → grounded, so
the HIGH prompt carries the ``<tools>`` section. Its line 6 carries
the sentinel ``reese-sentinel-42`` exactly once — the deterministic
match of the agent's (bare or scoped) grep.
Test → observable mapping (Playwright Mapping Rule; the story is the
owner's TODO item — one Playwright file per story, A16):
1. ``test_scoped_grep_line_and_status_show_the_scope`` — the scoped
question → the SSE ``tool`` frame is
``{"type": "tool", "name": "grep", "argument": <pattern>, "path":
<source/path>}`` (the scope rides the wire); while the turn is
live, the status carries "… is searching for <pattern> in
<source/path>"; the tool line reads
``🔎 Searching for <pattern> in <source/path>`` with TWO
``<code>`` spans (pattern, then scope) and the `` in `` text node
between them (the textContent structure, not a regex over the
whole line — the phase-139 range-suffix pin style); the answer
quotes the matched line (the scoped grep found it).
2. ``test_bare_grep_is_byte_identical`` — the EXISTING bare-grep
question (the phase-68/70 scripted flow — the byte-identity pin) →
the SSE ``tool`` frame has NO ``path`` key (ABSENT, never null);
the tool line is exactly ``🔎 Searching for <pattern>`` (ONE
``<code>`` span, NO ``.grep-scope`` span); the status label is the
pre-phase "… is searching for <pattern>" (no `` in `` extension);
the auto-saved record keeps the pre-phase shape (NO ``path`` key);
and the record restored through ``/?chat=<id>`` renders the
pre-phase line byte-identically (a no-key record IS the pre-phase
saved shape — the backward-compat pin, end to end).
3. ``test_scoped_scope_survives_save_restore`` — the scoped question
→ the live line carries the scope → the conversation auto-saves
(phase 55, silent) with ``path`` on the tool record →
``/?chat=<id>`` re-renders the saved record → the scope comes
back through the SAME structure (two ``<code>`` spans + the
`` in `` text node + the ``.grep-scope`` span), with the
phase-117 disclosure FOLDED (a restored turn is one compact
"Tool call(s) (N)" line — the shape is unchanged).
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from tests.e2e.mock_llm import SEARCH_PATTERN, SEARCH_TRIGGER
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "search_docs"
#: The one imported document (the importer derives ``source`` from the
#: fixture root's name and ``path`` from the file's relative position)
#: — the SAME fixture the bare-grep scripted flow already greps.
SEED_SOURCE = "search_docs"
SEED_PATH = "reese-notes.md"
SEED_SP = f"{SEED_SOURCE}/{SEED_PATH}"
#: The fixture's sentinel line (line 6) — the mock's grep (bare or
#: scoped) matches it exactly once, and its ``text`` part is what the
#: "Found …" answer quotes. Pinned against the fixture file itself
#: below.
SENTINEL_LINE = f"The offsite vault passphrase marker is {SEARCH_PATTERN}."
FOUND_ANSWER = f"Found {SENTINEL_LINE[:80]}"
#: Carries ``SCOPED_SEARCH_TRIGGER`` and is on-topic (the SAME
#: vocabulary as the bare question — cosine ≈0.51 against the fixture
#: + FTS hits → HIGH gate, the ``<tools>`` section rides along).
SCOPED_QUESTION = (
"Search within one document: the vault passphrase marker in my "
"homelab kubernetes backup notes?"
)
assert "search within one document" in SCOPED_QUESTION.lower()
#: The EXISTING bare-grep question (byte-identical to
#: ``test_search_tool.py``'s — the phase-68/70 scripted flow is the
#: byte-identity pin, untouched by this phase).
BARE_QUESTION = (
"Search your documents for the vault passphrase marker in my homelab "
"kubernetes backup notes?"
)
assert SEARCH_TRIGGER in BARE_QUESTION.lower()
# The two questions must each carry EXACTLY their own trigger — no
# other mock marker flow may fire (the phase-68 collision guard).
for other in (
"search within one document",
"search your documents",
"use your tools",
"read two documents",
"write a long answer",
"think in paragraphs",
"think out loud",
"show the end of your notes",
"show me a table",
"show me the blockquote",
"fail then answer",
"always fail",
"embed fail once",
"fail first turn",
"partial then fail",
"emit raw tool markup",
"list the files in this directory",
"what are the correct llama.cpp arguments",
"drill down the tree",
"read the capped document",
"read the suggested document",
"show me the picture of",
"which diagram is in your notes",
"quote the image reference",
"read the lines",
"read the same file twice",
"read two ranges",
"original source link",
"answer first, then list, then think",
):
if other == "search within one document":
assert other in SCOPED_QUESTION.lower()
assert other not in BARE_QUESTION.lower()
elif other == "search your documents":
assert other not in SCOPED_QUESTION.lower()
assert other in BARE_QUESTION.lower()
else:
assert other not in SCOPED_QUESTION.lower(), other
assert other not in BARE_QUESTION.lower(), other
def _pin_fixture() -> None:
"""The fixture carries the sentinel on line 6, exactly once."""
content = (FIXTURES / SEED_PATH).read_text(encoding="utf-8")
lines = content.split("\n")
assert lines[5] == SENTINEL_LINE, lines[5]
assert sum(SEARCH_PATTERN in line for line in lines) == 1
# --------------------------------------------------------------------------
# DB seeding (TRUNCATE-then-import via the real importer — the
# test_search_tool.py pattern, reused as-is: the same admin-import
# pipeline the admin Sources page drives, against the session app's
# DB)
# --------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test
body (the established house helper).
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int) -> ImportSummary:
"""Truncate the KB (plus the prompt-shaping tables), then re-import.
``steering_notes`` / ``kb_overview`` are truncated too, so the HIGH
prompt is exactly ``<relevance>`` + ``<documents>`` + ``<tools>``
regardless of leftovers from other suites — byte-stable prompts,
byte-stable answers. ``saved_chats`` is truncated too: the
auto-save rows are global state, and a leftover row with the same
auto-title would race ``_wait_saved_row`` (the E2E isolation
pattern — this suite runs in isolation, A16)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, saved_chats"
)
)
db.commit()
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary is not None and summary.added == 1, summary
return summary
# --------------------------------------------------------------------------
# Page + API helpers (the test_search_tool.py / test_read_range_tool_lines
# house patterns)
# --------------------------------------------------------------------------
#: Records every value #send-status takes during the turn — the
#: transient "… is searching for <pattern> in <source/path>"
#: calling-tool state is held only from the first `tool` frame until
#: the first answer delta, so the pre-submit observer is the
#: deterministic source of truth for it (no polling race — the
#: phase-44 task-03 flake fix).
STATUS_RECORDER = """
() => {
if (window.__statusesInstalled) return;
window.__statusesInstalled = true;
window.__statuses = [];
const el = document.querySelector('#send-status');
if (!el) return;
const rec = (v) => {
const l = window.__statuses;
if (!l.length || l[l.length - 1] !== v) l.push(v);
};
rec(el.textContent);
new MutationObserver(() => rec(el.textContent)).observe(el, {
childList: true,
subtree: true,
});
}
"""
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
#: (a response clone read in the background) — wire-level assertions
#: for the ``tool`` frames, independent of the UI rendering.
SSE_HOOK = """
() => {
if (window.__sseInstalled) return;
window.__sseInstalled = true;
window.__sseFrames = [];
const origFetch = window.fetch;
window.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
if (url.includes('/api/chat')) {
res.clone().text().then((bodyText) => {
for (const block of bodyText.split('\\n\\n')) {
const line = block.trim();
if (line.startsWith('data: ')) {
window.__sseFrames.push(line.slice(6));
}
}
});
}
} catch (e) { /* non-clonable responses: ignored */ }
return res;
};
}
"""
def _install_page_hooks(page: Page) -> None:
"""Install all hooks on the loaded page (post-goto, pre-submit)."""
page.evaluate(SSE_HOOK)
page.evaluate(STATUS_RECORDER)
def _frames(page: Page) -> list[dict]:
"""The captured SSE frames, once the hook's background read settles.
The hook reads ``res.clone().text()`` in a background promise that
resolves right after the stream closes — poll briefly until the
final ``done`` frame lands (fail loud if the hook captured nothing).
"""
deadline = time.monotonic() + 10.0
while True:
raw = page.evaluate("() => window.__sseFrames || []")
parsed = [json.loads(line) for line in raw if line]
if any(f.get("type") == "done" for f in parsed):
return parsed
if time.monotonic() > deadline:
raise AssertionError(
f"SSE hook captured no `done` frame (frames so far: "
f"{len(parsed)}) — hook install failed?"
)
time.sleep(0.05)
def _tool_frames(frames: list[dict]) -> list[dict]:
return [f for f in frames if f.get("type") == "tool"]
def _submit(page: Page, question: str) -> None:
page.evaluate("window.__sseFrames = []")
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button recovered.
Phase 48: the label assertion carries the settle wait with an
explicit timeout — the in-flight button is the enabled Stop control
(never disabled), so ``to_be_enabled`` no longer blocks until the
turn settles, and Playwright expect's default (5s) does not inherit
the page default."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
def _assert_no_error_banner(page: Page) -> None:
"""The turn must settle WITHOUT the terminal error banner (the red
role=alert error banner — the KB-offline banner is a separate,
non-error state)."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_attribute("role", "alert")
expect(banner).not_to_have_class(re.compile(r"is-error"))
def _tool_line(page: Page):
"""The turn's single tool line (the last .tool-call in the last
brain message)."""
return page.locator(".msg.brain .tool-call").last
def _assert_scoped_line(line, timeout: int = 30_000) -> None:
"""THE SUBJECT (shared by live + restore): the scoped grep's line
reads ``🔎 Searching for <pattern> in <source/path>`` — 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). State
assertions only (they retry until the boot load renders the line,
and they pass on the HIDDEN line of a folded disclosure —
phase 117: a restored turn renders folded)."""
expect(line).to_have_count(1, timeout=timeout)
expect(line).to_have_text(
f"🔎 Searching for {SEARCH_PATTERN} in {SEED_SP}", timeout=timeout
)
codes = line.locator("code")
expect(codes).to_have_count(2, timeout=timeout)
expect(codes.nth(0)).to_have_text(SEARCH_PATTERN, timeout=timeout)
expect(codes.nth(1)).to_have_text(SEED_SP, timeout=timeout)
scope_span = line.locator(".grep-scope")
expect(scope_span).to_have_count(1, timeout=timeout)
expect(scope_span).to_have_text(f" in {SEED_SP}", timeout=timeout)
def _assert_bare_line(line, timeout: int = 30_000) -> None:
"""The byte-identity pin: the bare grep's line is EXACTLY the
pre-phase line — ONE ``<code>`` span (the pattern), NO
``.grep-scope`` span, no extra bytes. (State assertions only —
same retry/hidden-line contract as ``_assert_scoped_line``.)"""
expect(line).to_have_count(1, timeout=timeout)
expect(line).to_have_text(f"🔎 Searching for {SEARCH_PATTERN}", timeout=timeout)
expect(line.locator("code")).to_have_count(1, timeout=timeout)
expect(line.locator("code")).to_have_text(SEARCH_PATTERN, timeout=timeout)
expect(line.locator(".grep-scope")).to_have_count(0, timeout=timeout)
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form
login — used to call the admin API with plain httpx."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _wait_saved_row(
app_url: str, cookies: dict[str, str], title: str, messages: int = 2
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
so there is no status line to wait on). Poll the admin list until
the row with the conversation's auto-title appears with the
expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
last = _find_row(r.json()["chats"], title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _saved_tool_record(
app_url: str, cookies: dict[str, str], chat_id: str
) -> dict[str, Any]:
"""The last brain record's single tool record, as the API returns
it (the saved ``bor.chat.v1`` JSONB through the boundary)."""
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200, r.text
messages = r.json()["messages"]
brain = [m for m in messages if m["who"] == "brain"]
assert len(brain) == 1, messages
tools = brain[0].get("tools")
assert isinstance(tools, list) and len(tools) == 1, messages
return tools[0]
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
# --------------------------------------------------------------------------
# 1. The scoped grep: the tool line + status label show the scope
# --------------------------------------------------------------------------
def test_scoped_grep_line_and_status_show_the_scope(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, SCOPED_QUESTION)
# The "calling tool" STATUS window is transient: the first `tool`
# frame sets #send-status and it holds until the FIRST answer delta
# (the agent loop completes before the answer stream) — the
# pre-submit MutationObserver records are the deterministic source
# of truth for the status transitions (the phase-44 flake fix).
_wait_settled(page)
# The scoped STATUS label (while the turn is live — recorded before
# the first delta replaced it): "… is searching for <pattern> in
# <source/path>", AFTER the "… is thinking" state.
statuses = page.evaluate("() => window.__statuses")
i_search = next(
(
i
for i, s in enumerate(statuses)
if s.endswith(f"is searching for {SEARCH_PATTERN} in {SEED_SP}")
),
None,
)
assert i_search is not None, statuses
i_think = next(
(i for i, s in enumerate(statuses) if "is thinking" in s), None
)
assert i_think is not None and i_think < i_search, statuses
# Wire level: exactly ONE `tool` frame — the grep carrying the
# PATTERN as its argument AND the scope as the additive `path`
# field (phase 146 task 01) — ahead of the first `delta` frame.
frames = _frames(page)
assert _tool_frames(frames) == [
{
"type": "tool",
"name": "grep",
"argument": SEARCH_PATTERN,
"path": SEED_SP,
}
]
first_delta = next(i for i, f in enumerate(frames) if f.get("type") == "delta")
assert all(
i < first_delta for i, f in enumerate(frames) if f.get("type") == "tool"
)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False
# THE SUBJECT: ONE tool line — the scope rendered after the
# pattern (two <code> spans + the " in " text node between them).
_assert_scoped_line(_tool_line(page))
# The answer quotes the MATCHED LINE — the agent's REAL scoped grep
# found the sentinel in the named document (the mock's echo).
bubble = page.locator(".msg.brain .bubble").last
expect(bubble).to_contain_text(FOUND_ANSWER)
# The turn settled to idle WITHOUT the error banner.
_assert_no_error_banner(page)
# --------------------------------------------------------------------------
# 2. The byte-identity pin: a BARE grep renders exactly as pre-phase —
# on the wire, on the line, in the status, and in the saved record
# --------------------------------------------------------------------------
def test_bare_grep_is_byte_identical(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, BARE_QUESTION)
_wait_settled(page)
# Wire level: the bare grep's frame is EXACTLY the pre-phase shape —
# the `path` key is ABSENT (never null).
frames = _frames(page)
tool_frames = _tool_frames(frames)
assert len(tool_frames) == 1, tool_frames
assert "path" not in tool_frames[0], tool_frames[0]
assert tool_frames[0] == {
"type": "tool",
"name": "grep",
"argument": SEARCH_PATTERN,
}, tool_frames[0]
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False
# The tool line: byte-identical to pre-phase (one <code>, no scope
# span — the phase-70/87/117 shape).
_assert_bare_line(_tool_line(page))
# The status label: the EXACT pre-phase "… is searching for
# <pattern>" — no " in <scope>" extension for a bare grep.
statuses = page.evaluate("() => window.__statuses")
assert any(s.endswith(f"is searching for {SEARCH_PATTERN}") for s in statuses), (
statuses
)
assert not any(
f"is searching for {SEARCH_PATTERN} in" in s for s in statuses
), statuses
# The saved record: the auto-save (phase 55, silent) persists the
# toolAcc record — a bare grep keeps the EXACT pre-phase shape (NO
# `path` key — the additive key is stamped only when the frame
# carried one).
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(BARE_QUESTION))
try:
record = _saved_tool_record(app_url, cookies, row["id"])
assert "path" not in record, record
assert record["name"] == "grep", record
assert record["argument"] == SEARCH_PATTERN, record
# And the no-key record (== the pre-phase saved shape) restores
# byte-identically through /?chat=<id> (the phase-50
# backward-compat rule, end to end). The restored line rides the
# FOLDED disclosure (phase 117) — the state assertions pass on
# the hidden line and retry until the boot load renders it.
page.goto(app_url + f"/?chat={row['id']}")
_assert_bare_line(_tool_line(page))
finally:
_delete_chat(app_url, cookies, row["id"])
# --------------------------------------------------------------------------
# 3. The scope survives save → restore: the saved record's additive
# `path` re-renders the same line on /?chat=<id> (the phase-117
# disclosure folded — the shape is unchanged)
# --------------------------------------------------------------------------
def test_scoped_scope_survives_save_restore(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_pin_fixture()
page.set_default_timeout(30_000)
_reset_db(mock_llm)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, SCOPED_QUESTION)
_wait_settled(page)
# The LIVE line carries the scope (the save point persists the
# toolAcc record — {name, argument, path}).
_assert_scoped_line(_tool_line(page))
# The conversation auto-saved (phase 55 — silent). Wait for the row
# by its auto-title (the first question), then pin the persisted
# record: the scope rides `path` ADDITIVELY on the tool record.
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(SCOPED_QUESTION))
try:
record = _saved_tool_record(app_url, cookies, row["id"])
assert record["name"] == "grep", record
assert record["argument"] == SEARCH_PATTERN, record
assert record["path"] == SEED_SP, record
# Restore: /?chat=<id> boots into the saved conversation
# through the SAME renderStoredMessage loop as the local
# restore — the saved record's additive `path` re-renders the
# scope through the SAME structure (the phase-139 save →
# restore pattern). The restored line rides the FOLDED
# disclosure (phase 117) — the state assertions pass on the
# hidden line and retry until the boot load renders it.
page.goto(app_url + f"/?chat={row['id']}")
_assert_scoped_line(_tool_line(page))
# Phase 117 (D3): a restored turn renders FOLDED — one compact
# "Tool call(s) (N)" line (the disclosure shape is unchanged by
# this phase: the `open` attribute is ABSENT).
disclosure = page.locator(".msg.brain .tool-calls-disclosure").last
expect(disclosure).not_to_have_attribute("open", timeout=30_000)
expect(disclosure.locator("summary")).to_have_text(
"Tool call (1)", timeout=30_000
)
# The answer text restored too (the record is intact).
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
FOUND_ANSWER, timeout=15_000
)
finally:
_delete_chat(app_url, cookies, row["id"])
+18
View File
@@ -95,6 +95,24 @@ FULL_BRAIN: dict[str, Any] = {
"offset": 42,
"limit": 30,
},
# Phase 146 (task 02): a SCOPED grep's record — the additive
# ``path`` key (the re-searched document's combined
# ``source/path``) round-trips through the boundary + Postgres,
# so the restore path re-renders the scope. A non-scoped
# record WITHOUT the key (the next two entries — every
# pre-phase and bare-grep shape) stores/returns WITHOUT it
# (ABSENT, never null — the omission serializer): those
# entries' exact-dict round-trip is the byte-identity pin.
{
"name": "grep",
"argument": "reese-sentinel-42",
"path": "search_docs/reese-notes.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
},
{
"name": "ls",
"argument": None,
+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"