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
|
**calling-tool status** alongside "thinking" (the send button keeps its
|
||||||
busy state — "Stop" — for the whole turn).
|
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:
|
The model may call tools as many times as needed, bounded by a round cap:
|
||||||
|
|
||||||
| Env | Default | Meaning |
|
| Env | Default | Meaning |
|
||||||
|
|||||||
@@ -941,12 +941,36 @@ async def chat(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
limit = limit if _whole_line_number(limit) 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(
|
yield sse_event(
|
||||||
ChatToolEvent(
|
ChatToolEvent(
|
||||||
name=piece.name,
|
name=piece.name,
|
||||||
argument=argument,
|
argument=argument,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
path=scope,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|||||||
+58
-2
@@ -188,8 +188,11 @@ class ChatToolEvent(BaseModel):
|
|||||||
grounded turn may call the server-side document tools (``ls`` /
|
grounded turn may call the server-side document tools (``ls`` /
|
||||||
``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested
|
``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested
|
||||||
call streams as ``{type: "tool", name: str, argument: str | null}``
|
call streams as ``{type: "tool", name: str, argument: str | null}``
|
||||||
ahead of the answer's ``delta`` frames. ``argument`` is the single
|
ahead of the answer's ``delta`` frames — a scoped ``grep``'s frame
|
||||||
string argument the model passed — ``read``'s ``path`` (the combined
|
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
|
``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` — or
|
||||||
null (a non-string value, a model error the backend refuses, and an
|
null (a non-string value, a model error the backend refuses, and an
|
||||||
omitted argument both yield null). The client renders each frame as
|
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
|
result, and the frame carries no range). The client ignores the
|
||||||
extra fields (PLAN §4); they exist so the wire — and the phase-131
|
extra fields (PLAN §4); they exist so the wire — and the phase-131
|
||||||
story E2E — can assert the range the model requested.
|
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"
|
type: Literal["tool"] = "tool"
|
||||||
@@ -218,6 +236,10 @@ class ChatToolEvent(BaseModel):
|
|||||||
argument: str | None = None # the single string argument passed, or null
|
argument: str | None = None # the single string argument passed, or null
|
||||||
offset: int | None = None # read only: the 1-based first line requested
|
offset: int | None = None # read only: the 1-based first line requested
|
||||||
limit: int | None = None # read only: the line count 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")
|
@model_serializer(mode="wrap")
|
||||||
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
||||||
@@ -229,6 +251,11 @@ class ChatToolEvent(BaseModel):
|
|||||||
data.pop("offset", None)
|
data.pop("offset", None)
|
||||||
if self.limit is None:
|
if self.limit is None:
|
||||||
data.pop("limit", 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
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -1010,6 +1037,20 @@ class ToolCall(BaseModel):
|
|||||||
422s here is one the UI never legitimately produces). A saved chat
|
422s here is one the UI never legitimately produces). A saved chat
|
||||||
written before the phase (no fields) validates UNCHANGED and renders
|
written before the phase (no fields) validates UNCHANGED and renders
|
||||||
without the suffix (the phase-50 backward-compat rule).
|
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)
|
name: str = Field(max_length=100)
|
||||||
@@ -1019,6 +1060,21 @@ class ToolCall(BaseModel):
|
|||||||
chars_total: int | None = Field(default=None, ge=0)
|
chars_total: int | None = Field(default=None, ge=0)
|
||||||
offset: int | None = Field(default=None, ge=1)
|
offset: int | None = Field(default=None, ge=1)
|
||||||
limit: 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 —
|
#: 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
|
* (phase 14) carry the old names, so both generations render — no
|
||||||
* migration. The content marks (the read/search/list glyphs) stay the
|
* migration. The content marks (the read/search/list glyphs) stay the
|
||||||
* exact tool-line template literals — the frontend emoji guard strips
|
* exact tool-line template literals — the frontend emoji guard strips
|
||||||
* precisely those in this file. */
|
* precisely those in this file.
|
||||||
function appendToolLine(wrap, name, argument, offset = null, limit = null) {
|
*
|
||||||
|
* 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");
|
const body = wrap?.querySelector?.(".msg-body");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
// Phase 117 (D2): the lines ride in a native <details> disclosure —
|
// 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");
|
const code = document.createElement("code");
|
||||||
code.textContent = argument; // the pattern is data, never markup
|
code.textContent = argument; // the pattern is data, never markup
|
||||||
line.appendChild(code);
|
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) {
|
} else if (name === "ls" && argument) {
|
||||||
line.textContent = "🔎 Listing documents in ";
|
line.textContent = "🔎 Listing documents in ";
|
||||||
const code = document.createElement("code");
|
const code = document.createElement("code");
|
||||||
@@ -2087,7 +2116,14 @@ function renderStoredMessage(m) {
|
|||||||
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
|
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
|
||||||
const tLimit =
|
const tLimit =
|
||||||
Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null;
|
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) {
|
if (t.truncated && arg) {
|
||||||
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
|
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;
|
Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null;
|
||||||
const frameLimit =
|
const frameLimit =
|
||||||
Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null;
|
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 };
|
const toolRecord = { name, argument };
|
||||||
if (frameOffset !== null) toolRecord.offset = frameOffset;
|
if (frameOffset !== null) toolRecord.offset = frameOffset;
|
||||||
if (frameLimit !== null) toolRecord.limit = frameLimit;
|
if (frameLimit !== null) toolRecord.limit = frameLimit;
|
||||||
|
if (framePath !== null) toolRecord.path = framePath;
|
||||||
toolAcc.push(toolRecord);
|
toolAcc.push(toolRecord);
|
||||||
clearTurnTimeout(); // the stream is alive — a frame arrived
|
clearTurnTimeout(); // the stream is alive — a frame arrived
|
||||||
if (!wrap) wrap = addMessage("brain", "");
|
if (!wrap) wrap = addMessage("brain", "");
|
||||||
@@ -3201,7 +3251,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
|||||||
(name === "read" || name === "read_document") && argument
|
(name === "read" || name === "read_document") && argument
|
||||||
? `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`
|
? `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`
|
||||||
: (name === "grep" || name === "search_documents") && argument
|
: (name === "grep" || name === "search_documents") && argument
|
||||||
? `${brand()} is searching for ${argument}`
|
? `${brand()} is searching for ${argument}${framePath ? ` in ${framePath}` : ""}`
|
||||||
: name === "ls" && argument
|
: name === "ls" && argument
|
||||||
? `${brand()} is listing documents in ${argument}`
|
? `${brand()} is listing documents in ${argument}`
|
||||||
: `${brand()} is listing documents`;
|
: `${brand()} is listing documents`;
|
||||||
@@ -3211,7 +3261,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
|||||||
.querySelector("#typing-indicator .bubble")
|
.querySelector("#typing-indicator .bubble")
|
||||||
?.setAttribute("aria-label", toolStatus);
|
?.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
|
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
|
||||||
// baseline resets per line, so each line counts its OWN
|
// baseline resets per line, so each line counts its OWN
|
||||||
// frameless silence. This live branch is the ONLY arm call site
|
// 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
|
specific phrase — same convention as ``think in paragraphs``); no
|
||||||
existing E2E question or fixture file contains the trigger, so
|
existing E2E question or fixture file contains the trigger, so
|
||||||
every other suite is unaffected.
|
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``
|
- user message containing ``emit raw tool markup``
|
||||||
(``SCAFFOLD_TRIGGER``, phase 71, tool-scaffolding guardrails — the
|
(``SCAFFOLD_TRIGGER``, phase 71, tool-scaffolding guardrails — the
|
||||||
2026-09-03 incident where a deflected round streamed the model's
|
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``).
|
#: convention of ``END_OF_NOTES_TRIGGER``).
|
||||||
SEARCH_PATTERN = "reese-sentinel-42"
|
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
|
#: Phase 44 (markdown-tables story, TODO.md L6): a user message
|
||||||
#: containing this substring (case-insensitive) gets the fixed table
|
#: containing this substring (case-insensitive) gets the fixed table
|
||||||
#: answer (``TABLE_ANSWER`` below) — a 3-column table, an XSS probe
|
#: 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",)
|
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:
|
def _scaffold_flow(body: dict[str, Any]) -> str | None:
|
||||||
"""Classify a phase-71 scaffolding request (see the module docstring).
|
"""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",
|
media_type="text/event-stream",
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
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 —
|
# Phase 68 (search tool): the deterministic search marker flow —
|
||||||
# checked BEFORE the phase-37 tool flow (the more specific
|
# checked BEFORE the phase-37 tool flow (the more specific
|
||||||
# trigger phrase wins, same convention as THINK_PARAS_TRIGGER).
|
# 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,
|
"offset": 42,
|
||||||
"limit": 30,
|
"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",
|
"name": "ls",
|
||||||
"argument": None,
|
"argument": None,
|
||||||
|
|||||||
@@ -197,7 +197,9 @@ def test_arm_has_exactly_one_live_call_site() -> None:
|
|||||||
delta_idx = js.find('ev.type === "delta"')
|
delta_idx = js.find('ev.type === "delta"')
|
||||||
branch = js[tool_idx:delta_idx]
|
branch = js[tool_idx:delta_idx]
|
||||||
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
|
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)")
|
arm = branch.find("armToolLineClock(wrap)")
|
||||||
assert -1 < append < arm, (
|
assert -1 < append < arm, (
|
||||||
"the arm follows the line's append — the baseline resets per line"
|
"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])
|
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:
|
class _FakeSteeringResult:
|
||||||
"""Empty steering-note result (no stored notes in these unit tests)."""
|
"""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) ----------
|
# ---------- 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, (
|
assert "uiState === UI_STATE.thinking" in branch, (
|
||||||
"label updates only while the state is still thinking"
|
"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).
|
# No setUiState in the branch: the state stays "thinking" (never stale).
|
||||||
assert "setUiState" not in branch, (
|
assert "setUiState" not in branch, (
|
||||||
"the tool branch must keep uiState=thinking (button stays disabled)"
|
"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 read status requires the name (new or legacy) AND a string argument"
|
||||||
)
|
)
|
||||||
# The search status — new + legacy name, sitting BETWEEN the read
|
# 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 '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
|
# Phase 70: the scoped ls status mirrors the scoped tool line; the
|
||||||
# unscoped listing stays the final fallback.
|
# unscoped listing stays the final fallback.
|
||||||
assert 'name === "ls" && argument' in branch
|
assert 'name === "ls" && argument' in branch
|
||||||
@@ -227,9 +236,11 @@ def test_tool_lines_re_render_on_restore() -> None:
|
|||||||
end = js.find("function restoreConversation")
|
end = js.find("function restoreConversation")
|
||||||
body = js[fn:end]
|
body = js[fn:end]
|
||||||
assert "Array.isArray(m.tools)" in body
|
assert "Array.isArray(m.tools)" in body
|
||||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, (
|
assert (
|
||||||
"phase 139: the stored range rides the SAME helper — a pre-phase"
|
"appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||||
" record (no fields) renders byte-identical"
|
), (
|
||||||
|
"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)")
|
thinking_restore = body.find("if (m.thinking)")
|
||||||
tools_restore = body.find("Array.isArray(m.tools)")
|
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:
|
def test_append_tool_line_signature_takes_optional_range() -> None:
|
||||||
"""appendToolLine gains the additive ``offset``/``limit`` params
|
"""appendToolLine gains the additive ``offset``/``limit`` params
|
||||||
(default ``null`` — a pre-phase 3-arg call renders byte-identical),
|
(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")
|
body = _extract(_js(), "appendToolLine")
|
||||||
assert (
|
assert (
|
||||||
"function appendToolLine(wrap, name, argument, offset = null, limit = null)"
|
"function appendToolLine(wrap, name, argument, offset = null, limit = null, "
|
||||||
in _js()
|
"scope = null)" in _js()
|
||||||
), "the signature carries the additive offset/limit (default null)"
|
), "the signature carries the additive offset/limit/scope (default null)"
|
||||||
assert "readRangeSuffix(offset, limit)" in body, (
|
assert "readRangeSuffix(offset, limit)" in body, (
|
||||||
"the read arm renders the suffix through the SAME helper"
|
"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"
|
"limit joins the record ONLY when non-null"
|
||||||
)
|
)
|
||||||
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
|
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
|
||||||
# Both values pass to the SAME helper (the line + the status label).
|
# Phase 146 (task 02): the scope joins the SAME two pass-throughs.
|
||||||
assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch
|
assert (
|
||||||
|
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
|
||||||
|
in branch
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_live_read_status_carries_the_range_suffix() -> None:
|
def test_live_read_status_carries_the_range_suffix() -> None:
|
||||||
"""ASSUMPTION A2: the read status label carries the SAME
|
"""ASSUMPTION A2: the read status label carries the SAME
|
||||||
requested-range suffix as the line (``readRangeSuffix`` — the single
|
requested-range suffix as the line (``readRangeSuffix`` — the single
|
||||||
source of truth); a non-range read renders byte-identical (the
|
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()
|
js = _js()
|
||||||
tool_idx = js.find('ev.type === "tool"')
|
tool_idx = js.find('ev.type === "tool"')
|
||||||
delta_idx = js.find('ev.type === "delta"')
|
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)}`"
|
"`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`"
|
||||||
in branch
|
in branch
|
||||||
), "the read status carries the suffix (A2)"
|
), "the read status carries the suffix (A2)"
|
||||||
# The other labels stay byte-identical (no suffix).
|
# The other labels: the grep scope extension (phase 146) + the
|
||||||
assert "`${brand()} is searching for ${argument}`" in branch
|
# 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 ${argument}`" in branch
|
||||||
assert "`${brand()} is listing documents`" 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, (
|
assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body, (
|
||||||
"the stored limit reads through the house guard"
|
"the stored limit reads through the house guard"
|
||||||
)
|
)
|
||||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, (
|
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body, (
|
||||||
"the stored range rides the SAME helper"
|
"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
|
assert fn != -1
|
||||||
end = js.find("function restoreConversation")
|
end = js.find("function restoreConversation")
|
||||||
body = js[fn:end]
|
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, (
|
assert "t.truncated && arg" in body, (
|
||||||
"only an argument-bearing (Reading) record with the flag renders"
|
"only an argument-bearing (Reading) record with the flag renders"
|
||||||
" the marker"
|
" the marker"
|
||||||
@@ -173,7 +173,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
|
|||||||
" Number(t.chars_total) || 0)" in body
|
" Number(t.chars_total) || 0)" in body
|
||||||
)
|
)
|
||||||
# The marker append sits INSIDE the tools loop, after the line append.
|
# 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,")
|
note_idx = body.find("appendTruncatedNote(wrap, arg,")
|
||||||
assert -1 < line_idx < note_idx
|
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
|
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:
|
def test_retry_frame_serializes_exactly() -> None:
|
||||||
"""Phase 67: the ``retry`` frame is exactly ``{type: "retry",
|
"""Phase 67: the ``retry`` frame is exactly ``{type: "retry",
|
||||||
attempt: int, max_attempts: int}`` — a transient status the client's
|
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")
|
fn = js.find("function renderStoredMessage")
|
||||||
assert fn != -1
|
assert fn != -1
|
||||||
body = js[fn : js.find("function restoreConversation", fn)]
|
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 "closeToolCalls(wrap)" in body
|
||||||
assert (
|
assert (
|
||||||
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)")
|
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
|
||||||
< body.find("closeToolCalls(wrap)")
|
< body.find("closeToolCalls(wrap)")
|
||||||
), "the fold sits AFTER the tool-line restore loop"
|
), "the fold sits AFTER the tool-line restore loop"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user