phase: 146_search_tool_scope
Phase 146 final verification pass complete — all green, tree untouched by me.
**Verified (final pass — both tasks already in `complete/`):**
- Scoped grep streams `{"type":"tool","name":"grep","argument":<pattern>,"path":<source/path>}`; bare grep/read/ls frames dump byte-identical (unit matrix in `test_sse_events.py` + builder pins in `test_chat_gate.py` L1364–1448)
- Tool line `🔎 Searching for <pattern> in <source/path>` (two `<code>` spans, `.grep-scope` sibling, no new emoji); bare line byte-identical (unit Node test + E2E)
- Status label "… is searching for X in Y" scoped-only; `path` persisted additively on the saved tool record and re-rendered on `/?chat=<id>` restore; pre-phase records restore byte-identically (E2E tests 2 & 3)
**Results (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **3419 passed**, `app/` coverage **99%** (>90% ✓)
- `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` → **3 passed** in isolation ✓
- `uv run ruff check . && uv run pyright` → clean (0 errors) ✓
**Completion criteria:** all 6 ✓ (wire shape, UI line, status label, save/restore, gates, no completed-phase behavior change — full suite green includes all prior-phase pins).
**Documentation:** README.md (incl. its phase-146 section) and AGENTS.md reviewed in full against the codebase — verified consistent, no changes needed (`.env.example` ⊆ README config tables; no stale vars; phase-100 72rem rule in AGENTS.md §5 matches shipped CSS; no-CDN/smoke-DB/gpgsign claims all confirmed).
**Notable:** none — no defects found, no live-infrastructure touched, nothing committed per harness protocol.
**Next pending phase:** `147_sync_changed_only`.
This commit is contained in:
@@ -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
|
||||
+17
@@ -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`
|
||||
+119
@@ -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
|
||||
+15
@@ -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`).
|
||||
+119
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"])
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) ----------
|
||||
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user