From ab3f5f685b01ea63476fa4660354c3cdf839e050 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Thu, 1 Oct 2026 12:48:04 -0400 Subject: [PATCH] phase: 146_search_tool_scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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":,"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 in ` (two `` 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=` 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`. --- .../146_search_tool_scope/00_phase.md | 0 .../01_tool_frame_scope.md | 0 .../02_ui_scope_render_e2e.md | 0 .../146_search_tool_scope__00_phase.a1.err | 0 .../146_search_tool_scope__00_phase.a1.md | 19 + ...46_search_tool_scope__00_phase.a1.validate | 119 ++++ ...rch_tool_scope__01_tool_frame_scope.a1.err | 0 ...arch_tool_scope__01_tool_frame_scope.a1.md | 17 + ...ool_scope__01_tool_frame_scope.a1.validate | 119 ++++ ..._tool_scope__02_ui_scope_render_e2e.a1.err | 0 ...h_tool_scope__02_ui_scope_render_e2e.a1.md | 15 + ..._scope__02_ui_scope_render_e2e.a1.validate | 119 ++++ README.md | 12 + app/api/chat.py | 24 + app/schemas.py | 60 +- frontend/assets/app.js | 60 +- tests/e2e/mock_llm.py | 106 +++ tests/e2e/test_search_tool_scope.py | 668 ++++++++++++++++++ tests/integration/test_chats_api.py | 18 + tests/unit/test_big_read_progress.py | 4 +- tests/unit/test_chat_gate.py | 161 +++++ tests/unit/test_frontend_tool_states.py | 23 +- tests/unit/test_read_range_tool_lines.py | 35 +- tests/unit/test_read_truncation_frontend.py | 4 +- tests/unit/test_search_tool_scope.py | 530 ++++++++++++++ tests/unit/test_sse_events.py | 63 ++ tests/unit/test_tool_call_compact.py | 4 +- 27 files changed, 2151 insertions(+), 29 deletions(-) rename .agents/phases/{todo => complete}/146_search_tool_scope/00_phase.md (100%) rename .agents/phases/{todo => complete}/146_search_tool_scope/01_tool_frame_scope.md (100%) rename .agents/phases/{todo => complete}/146_search_tool_scope/02_ui_scope_render_e2e.md (100%) create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.err create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.md create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.validate create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.err create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.md create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.validate create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.err create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.md create mode 100644 .agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.validate create mode 100644 tests/e2e/test_search_tool_scope.py create mode 100644 tests/unit/test_search_tool_scope.py diff --git a/.agents/phases/todo/146_search_tool_scope/00_phase.md b/.agents/phases/complete/146_search_tool_scope/00_phase.md similarity index 100% rename from .agents/phases/todo/146_search_tool_scope/00_phase.md rename to .agents/phases/complete/146_search_tool_scope/00_phase.md diff --git a/.agents/phases/todo/146_search_tool_scope/01_tool_frame_scope.md b/.agents/phases/complete/146_search_tool_scope/01_tool_frame_scope.md similarity index 100% rename from .agents/phases/todo/146_search_tool_scope/01_tool_frame_scope.md rename to .agents/phases/complete/146_search_tool_scope/01_tool_frame_scope.md diff --git a/.agents/phases/todo/146_search_tool_scope/02_ui_scope_render_e2e.md b/.agents/phases/complete/146_search_tool_scope/02_ui_scope_render_e2e.md similarity index 100% rename from .agents/phases/todo/146_search_tool_scope/02_ui_scope_render_e2e.md rename to .agents/phases/complete/146_search_tool_scope/02_ui_scope_render_e2e.md diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.err b/.agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.md b/.agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.md new file mode 100644 index 0000000..019d61b --- /dev/null +++ b/.agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.md @@ -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":,"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 in ` (two `` 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=` 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`. diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.validate b/.agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.validate new file mode 100644 index 0000000..0e146da --- /dev/null +++ b/.agents/reports/146_search_tool_scope/146_search_tool_scope__00_phase.a1.validate @@ -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 diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.err b/.agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.md b/.agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.md new file mode 100644 index 0000000..1da534c --- /dev/null +++ b/.agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.md @@ -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` diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.validate b/.agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.validate new file mode 100644 index 0000000..3b84713 --- /dev/null +++ b/.agents/reports/146_search_tool_scope/146_search_tool_scope__01_tool_frame_scope.a1.validate @@ -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 diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.err b/.agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.md b/.agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.md new file mode 100644 index 0000000..06f5b54 --- /dev/null +++ b/.agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.md @@ -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 in ` (`.grep-scope` span: " in " text node + second ``, sibling after the pattern ``); live `tool` branch reads `ev.path` (string guard), stamps `toolRecord.path` only when non-null, extends the grep status label with ` in `; 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`). diff --git a/.agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.validate b/.agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.validate new file mode 100644 index 0000000..0e146da --- /dev/null +++ b/.agents/reports/146_search_tool_scope/146_search_tool_scope__02_ui_scope_render_e2e.a1.validate @@ -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 diff --git a/README.md b/README.md index 1455d50..febee91 100644 --- a/README.md +++ b/README.md @@ -394,6 +394,18 @@ streamed as an SSE `tool` frame. In the chat, each call shows a transient **calling-tool status** alongside "thinking" (the send button keeps its busy state β€” "Stop" β€” for the whole turn). +**The search line shows where it searches (phase 146).** When a `grep` +re-searches one document (the optional `path`), the chat's tool line and +the calling-tool status show the document the search runs in β€” +`πŸ”Ž Searching for in ` β€” so a whole-KB search and +a re-search of a single document read as distinct searches instead of +looking like the model is repeating itself. The scope rides the SSE +`tool` frame additively (`path`, present on scoped greps only) and is +persisted on the saved turn's tool record, so it re-renders after a +save β†’ restore; a bare `grep` renders exactly as before (frame, line, +status, and saved shape all byte-identical to pre-phase β€” old saved +turns restore unchanged). + The model may call tools as many times as needed, bounded by a round cap: | Env | Default | Meaning | diff --git a/app/api/chat.py b/app/api/chat.py index 2dfee84..0b632da 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -941,12 +941,36 @@ async def chat( else None ) limit = limit if _whole_line_number(limit) else None + # Phase 146 (task 01): a SCOPED grep's frame + # carries the scope β€” the re-searched + # document's combined ``source/path`` (the + # grep's optional one-doc argument, PLAN Β§6) + # β€” as the additive ``path`` field, so the + # UI can show WHERE the search runs (the + # "looks like the LLM is repeating itself" + # confusion, TODO L3). ONLY ``grep`` reads it + # (a non-grep ``path`` argument is that + # tool's ``argument``, never a scope); the + # same string guard as ``argument`` β€” a + # non-string value is a model error the agent + # already refuses; the guard just yields + # None β†’ the key is omitted on + # serialization, so every non-scoped frame + # keeps its exact pre-phase wire bytes (the + # ChatToolEvent wrap serializer). + scope = ( + piece.arguments.get("path") + if piece.name == "grep" + else None + ) + scope = scope if isinstance(scope, str) else None yield sse_event( ChatToolEvent( name=piece.name, argument=argument, offset=offset, limit=limit, + path=scope, ).model_dump() ) continue diff --git a/app/schemas.py b/app/schemas.py index aa33943..2acdd6e 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -188,8 +188,11 @@ class ChatToolEvent(BaseModel): grounded turn may call the server-side document tools (``ls`` / ``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested call streams as ``{type: "tool", name: str, argument: str | null}`` - ahead of the answer's ``delta`` frames. ``argument`` is the single - string argument the model passed β€” ``read``'s ``path`` (the combined + ahead of the answer's ``delta`` frames β€” a scoped ``grep``'s frame + alone additionally carries the optional ``path`` (phase 146 β€” the + re-searched document's combined ``source/path``; the ONLY case the + field is present, see below). ``argument`` is the single string + argument the model passed β€” ``read``'s ``path`` (the combined ``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` β€” or null (a non-string value, a model error the backend refuses, and an omitted argument both yield null). The client renders each frame as @@ -211,6 +214,21 @@ class ChatToolEvent(BaseModel): result, and the frame carries no range). The client ignores the extra fields (PLAN Β§4); they exist so the wire β€” and the phase-131 story E2E β€” can assert the range the model requested. + + Phase 146 (task 01): ``path`` β€” the SCOPE of a scoped ``grep``: the + re-searched document's combined ``source/path`` identity (the + grep's optional one-doc scope, PLAN Β§6), so the UI can show WHERE + the search runs (the "looks like the LLM is repeating itself" + confusion, TODO L3). Additive in the ``offset`` / ``limit`` style: + absent (``None``) on every frame that is NOT a scoped grep β€” bare + ``grep``, ``ls``, ``read`` (with and without range), and every + pre-phase frame β€” where the key is DROPPED on serialization + (ABSENT, never ``null``), so those frames stay byte-identical to + pre-phase (the story E2E suites pin them as exact dicts). The frame + builder (:mod:`app.api.chat`) sets it ONLY for ``piece.name == + "grep"`` from the grep's ``path`` argument, string-guarded like + ``argument`` (a non-string value is a model error the agent already + refuses; the guard just yields the omission). """ type: Literal["tool"] = "tool" @@ -218,6 +236,10 @@ class ChatToolEvent(BaseModel): argument: str | None = None # the single string argument passed, or null offset: int | None = None # read only: the 1-based first line requested limit: int | None = None # read only: the line count requested + # Phase 146 (task 01): grep only β€” the scoped re-search's document + # (the model's ``path`` argument, combined ``source/path``); None on + # every non-scoped frame β†’ the key is ABSENT, never null. + path: str | None = None @model_serializer(mode="wrap") def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: @@ -229,6 +251,11 @@ class ChatToolEvent(BaseModel): data.pop("offset", None) if self.limit is None: data.pop("limit", None) + # Phase 146 (task 01): the scoped grep's scope rides ``path`` β€” + # ABSENT (never null) on every non-scoped frame, so those stay + # byte-identical to pre-phase (the same omission pattern). + if self.path is None: + data.pop("path", None) return data @@ -1010,6 +1037,20 @@ class ToolCall(BaseModel): 422s here is one the UI never legitimately produces). A saved chat written before the phase (no fields) validates UNCHANGED and renders without the suffix (the phase-50 backward-compat rule). + + Phase 146 (task 02): ``path`` β€” the SCOPE of a scoped ``grep``: + the re-searched document's combined ``source/path`` identity (the + same additive ``path`` the :class:`ChatToolEvent` frame carries, + PLAN Β§6) rides the SAME record, so a saved scoped-grep turn + re-renders the scope on restore (the chat page's restore path reads + it off the stored row). Additive-omission in the ``ChatToolEvent`` + / ``SourceRef.image_url`` style: ABSENT (never ``null``) on every + non-scoped record β€” the UI stamps the key only when the frame + carried a scope β€” so pre-phase and bare-grep records validate + UNCHANGED and round-trip byte-identically (the phase-50 + backward-compat rule; no migration). ``max_length=2000`` mirrors + ``argument`` (the combined ``source/path`` identity is ≀ 120 + 1 + + 1000; 2 000 is the same Γ—2 headroom). """ name: str = Field(max_length=100) @@ -1019,6 +1060,21 @@ class ToolCall(BaseModel): chars_total: int | None = Field(default=None, ge=0) offset: int | None = Field(default=None, ge=1) limit: int | None = Field(default=None, ge=1) + # Phase 146 (task 02): grep only β€” the scoped re-search's document + # (the frame's additive ``path``); None on every non-scoped record + # β†’ the key is ABSENT, never null (the serializer below). + path: str | None = Field(default=None, max_length=2000) + + @model_serializer(mode="wrap") + def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any: + # Phase 146 (task 02): the scope is ABSENT (never null) on + # every non-scoped record β€” the pre-phase / bare-grep saved + # shape stays byte-identical (the ChatToolEvent omission + # pattern; the FULL_BRAIN round-trip pin keys on it). + data = handler(self) + if self.path is None: + data.pop("path", None) + return data #: One suggestion chip (phase 83, A1): a short deterministic string β€” diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 96f0e68..babc168 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -1119,8 +1119,21 @@ function closeToolCalls(wrap) { * (phase 14) carry the old names, so both generations render β€” no * migration. The content marks (the read/search/list glyphs) stay the * exact tool-line template literals β€” the frontend emoji guard strips - * precisely those in this file. */ -function appendToolLine(wrap, name, argument, offset = null, limit = null) { + * precisely those in this file. + * + * Phase 146 (task 02): the grep arm gains the additive `scope` param + * (default null β€” the frame's `path` field / the saved record's + * `path`): a SCOPED grep's line shows WHERE the search runs β€” the + * pinned Searching label + the pattern , then " in " + the scope + * in a second β€” 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 (the phase-139 + * read-range-suffix precedent), appended ONLY when it is a non-empty + * string: a bare grep's line stays byte-identical to pre-phase (the + * phase-70/87/117 pins and the frontend emoji guard stay green β€” no + * new emoji). */ +function appendToolLine(wrap, name, argument, offset = null, limit = null, scope = null) { const body = wrap?.querySelector?.(".msg-body"); if (!body) return; // Phase 117 (D2): the lines ride in a native
disclosure β€” @@ -1172,6 +1185,22 @@ function appendToolLine(wrap, name, argument, offset = null, limit = null) { const code = document.createElement("code"); code.textContent = argument; // the pattern is data, never markup line.appendChild(code); + // Phase 146 (task 02): the SCOPE of a scoped grep β€” the re-searched + // document's combined source/path β€” appended as a SIBLING after the + // pattern (the phase-139 read-range-suffix precedent): a + // .grep-scope span holding the " in " text node + a second , + // ONLY when the scope is a non-empty string. A bare grep's line + // stays byte-identical to pre-phase (no span, no extra bytes); + // the scope is data, never markup (no new emoji). + if (typeof scope === "string" && scope) { + const scopeSpan = document.createElement("span"); + scopeSpan.className = "grep-scope"; + scopeSpan.appendChild(document.createTextNode(" in ")); + const scopeCode = document.createElement("code"); + scopeCode.textContent = scope; // the scope is data, never markup + scopeSpan.appendChild(scopeCode); + line.appendChild(scopeSpan); + } } else if (name === "ls" && argument) { line.textContent = "πŸ”Ž Listing documents in "; const code = document.createElement("code"); @@ -2087,7 +2116,14 @@ function renderStoredMessage(m) { Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null; const tLimit = Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null; - appendToolLine(wrap, t.name, arg, tOffset, tLimit); + // Phase 146 (task 02): a saved SCOPED grep (the live toolAcc + // stamp β€” the additive `path` key on the bor.chat.v1 record) + // re-renders the SAME scope through the SAME helper: the same + // string guard β†’ a record saved before the phase (no `path` + // key) renders byte-identically (the phase-95 truncation note + // is untouched β€” it keys on the argument, not the scope). + const tPath = typeof t.path === "string" && t.path ? t.path : null; + appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath); if (t.truncated && arg) { appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0); } @@ -3183,9 +3219,23 @@ async function runTurn(text, { reask = false, image = null } = {}) { Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null; const frameLimit = Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null; + // Phase 146 (task 02): the SCOPE of a scoped grep β€” the + // frame's additive `path` field (ABSENT on every non-scoped + // frame, never null β€” the ChatToolEvent serializer drops it + // when None). The SAME string guard as `argument` β†’ null when + // absent / malformed; the record gains `path` ONLY when + // non-null (a bare grep keeps the exact pre-phase + // {name, argument} shape β€” the bor.chat.v1 save JSONB is + // additive, old records restore byte-identically, no + // migration), and the line + the status label show WHERE the + // search runs (the "looks like the LLM is repeating itself" + // confusion, TODO.md L3). + const framePath = + typeof ev.path === "string" && ev.path ? ev.path : null; const toolRecord = { name, argument }; if (frameOffset !== null) toolRecord.offset = frameOffset; if (frameLimit !== null) toolRecord.limit = frameLimit; + if (framePath !== null) toolRecord.path = framePath; toolAcc.push(toolRecord); clearTurnTimeout(); // the stream is alive β€” a frame arrived if (!wrap) wrap = addMessage("brain", ""); @@ -3201,7 +3251,7 @@ async function runTurn(text, { reask = false, image = null } = {}) { (name === "read" || name === "read_document") && argument ? `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}` : (name === "grep" || name === "search_documents") && argument - ? `${brand()} is searching for ${argument}` + ? `${brand()} is searching for ${argument}${framePath ? ` in ${framePath}` : ""}` : name === "ls" && argument ? `${brand()} is listing documents in ${argument}` : `${brand()} is listing documents`; @@ -3211,7 +3261,7 @@ async function runTurn(text, { reask = false, image = null } = {}) { .querySelector("#typing-indicator .bubble") ?.setAttribute("aria-label", toolStatus); } - appendToolLine(wrap, name, argument, frameOffset, frameLimit); + appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath); // Phase 87 (TODO.md L5): arm this line's elapsed clock β€” the // baseline resets per line, so each line counts its OWN // frameless silence. This live branch is the ONLY arm call site diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 92c6f9f..fe05c17 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -170,6 +170,30 @@ Implements just enough of the aipi surface: specific phrase β€” same convention as ``think in paragraphs``); no existing E2E question or fixture file contains the trigger, so every other suite is unaffected. + - user message containing ``search within one document`` + (``SCOPED_SEARCH_TRIGGER``, phase 146 task 02 β€” the search tool's + SCOPE: a re-search of ONE document, so the UI can show where the + search runs and repeated patterns read as distinct searches, + TODO.md L3) **and** the system prompt carries the ```` + 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 `` (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 ```` section, so deflected turns never hit it); no + existing E2E question or fixture file contains the phrase, so + every other suite is unaffected. - user message containing ``emit raw tool markup`` (``SCAFFOLD_TRIGGER``, phase 71, tool-scaffolding guardrails β€” the 2026-09-03 incident where a deflected round streamed the model's @@ -1042,6 +1066,25 @@ SEARCH_TRIGGER = "search your documents" #: convention of ``END_OF_NOTES_TRIGGER``). SEARCH_PATTERN = "reese-sentinel-42" +#: Phase 146 (task 02 β€” the search tool's scope, TODO.md L3): a user +#: message containing this substring (case-insensitive) β€” combined with +#: the ```` section in the system prompt β€” drives the +#: deterministic SCOPED-SEARCH flow (``grep`` with ``pattern`` + +#: ``path`` β†’ the "Found …" answer), documented in the module +#: docstring. Disjoint from ``SEARCH_TRIGGER`` (the bare-grep flow β€” +#: the byte-identity pin) and from every other marker; verified 2026-10: +#: no existing E2E question or fixture file contains the phrase, so +#: every other suite is unaffected. +SCOPED_SEARCH_TRIGGER = "search within one document" + +#: The scoped flow's target β€” the SAME fixture document the bare-grep +#: flow already greps (``tests/fixtures/search_docs/`` β†’ source +#: ``search_docs``, doc ``reese-notes.md``): the agent's REAL scoped +#: grep finds its sentinel line, so the follow-up round carries a tool +#: result. The exact string the UI renders is the model's ``path`` +#: argument β€” the fixture's real combined identity. +SCOPED_SEARCH_PATH = "search_docs/reese-notes.md" + #: Phase 44 (markdown-tables story, TODO.md L6): a user message #: containing this substring (case-insensitive) gets the fixed table #: answer (``TABLE_ANSWER`` below) β€” a 3-column table, an XSS probe @@ -1708,6 +1751,43 @@ def _search_flow(body: dict[str, Any]) -> tuple[str, ...] | None: return ("search",) +def _scoped_search_flow(body: dict[str, Any]) -> tuple[str, ...] | None: + """Classify a SCOPED_SEARCH_TRIGGER request into a step. + + Phase 146 (task 02): the SCOPED search flow β€” the owner's "looks + like the LLM is repeating itself" scenario (TODO.md L3): a re-search + of ONE document (``grep(pattern, path)``) so the UI can show WHERE + the search runs. The same scripted-turn idiom as ``_search_flow`` + (the phase-70/72 grep branches): stateless classification from the + messages (streaming only). + + * ``("search",)`` β€” ``tools`` are offered and no search result is + in the messages yet: the model greps the ONE fixture document + (``SCOPED_SEARCH_PATH``) for ``SEARCH_PATTERN`` (id ``call_0``) β€” + the scoped form (``pattern`` + ``path``). + * ``("found", first_line)`` β€” a ``tool``-role search result is in + the messages (the agent's REAL scoped grep matched the fixture's + sentinel line): the model answers, quoting the first matched + line (``Found ``). + 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 + ```` 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 "" not in _system(body): + return None + first_line = _search_result_line(body) + if first_line is not None: + return ("found", first_line) + if not body.get("tools"): + return None + return ("search",) + + def _scaffold_flow(body: dict[str, Any]) -> str | None: """Classify a phase-71 scaffolding request (see the module docstring). @@ -3895,6 +3975,32 @@ def chat_completions(body: dict[str, Any]) -> Any: media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) + # Phase 146 (task 02): the deterministic SCOPED search marker + # flow β€” checked BEFORE the phase-68 search flow (disjoint + # trigger phrases β€” the phase-71/72 ordering convention; the + # trigger needs the ```` section, so deflected turns + # never hit it). The scoped form (pattern + path) is the wire + # subject: the app's tool frame carries the scope as its + # additive ``path`` field, and the UI shows WHERE the search + # runs (the "repeating itself" confusion, TODO.md L3). + scoped_search = _scoped_search_flow(body) + if scoped_search is not None: + if scoped_search[0] == "search": + stream = _tool_call_stream( + "grep", + {"pattern": SEARCH_PATTERN, "path": SCOPED_SEARCH_PATH}, + "call_0", + ) + else: # "found" β€” quote the first matched line (80 chars) + answer = _apply_max_tokens( + f"Found {scoped_search[1][:80]}", body.get("max_tokens") + ) + stream = _sse_stream(answer, 0.0) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) # Phase 68 (search tool): the deterministic search marker flow β€” # checked BEFORE the phase-37 tool flow (the more specific # trigger phrase wins, same convention as THINK_PARAS_TRIGGER). diff --git a/tests/e2e/test_search_tool_scope.py b/tests/e2e/test_search_tool_scope.py new file mode 100644 index 0000000..8a56426 --- /dev/null +++ b/tests/e2e/test_search_tool_scope.py @@ -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 ```` 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 ``. + +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 ```` 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": , "path": + }`` (the scope rides the wire); while the turn is + live, the status carries "… is searching for in + "; the tool line reads + ``πŸ”Ž Searching for in `` with TWO + ```` 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 `` (ONE + ```` span, NO ``.grep-scope`` span); the status label is the + pre-phase "… is searching for " (no `` in `` extension); + the auto-saved record keeps the pre-phase shape (NO ``path`` key); + and the record restored through ``/?chat=`` 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=`` re-renders the saved record β†’ the scope comes + back through the SAME structure (two ```` 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 ```` 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 ```` + ```` + ```` + 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 in " +#: 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 in `` β€” TWO + ```` 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 ```` 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 in + # ", 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 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 , 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 + # " β€” no " in " 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= (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= (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= 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"]) diff --git a/tests/integration/test_chats_api.py b/tests/integration/test_chats_api.py index 557120f..894f160 100644 --- a/tests/integration/test_chats_api.py +++ b/tests/integration/test_chats_api.py @@ -95,6 +95,24 @@ FULL_BRAIN: dict[str, Any] = { "offset": 42, "limit": 30, }, + # Phase 146 (task 02): a SCOPED grep's record β€” the additive + # ``path`` key (the re-searched document's combined + # ``source/path``) round-trips through the boundary + Postgres, + # so the restore path re-renders the scope. A non-scoped + # record WITHOUT the key (the next two entries β€” every + # pre-phase and bare-grep shape) stores/returns WITHOUT it + # (ABSENT, never null β€” the omission serializer): those + # entries' exact-dict round-trip is the byte-identity pin. + { + "name": "grep", + "argument": "reese-sentinel-42", + "path": "search_docs/reese-notes.md", + "truncated": False, + "chars_shown": None, + "chars_total": None, + "offset": None, + "limit": None, + }, { "name": "ls", "argument": None, diff --git a/tests/unit/test_big_read_progress.py b/tests/unit/test_big_read_progress.py index 48dd6ef..9c064fa 100644 --- a/tests/unit/test_big_read_progress.py +++ b/tests/unit/test_big_read_progress.py @@ -197,7 +197,9 @@ def test_arm_has_exactly_one_live_call_site() -> None: delta_idx = js.find('ev.type === "delta"') branch = js[tool_idx:delta_idx] assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock" - append = branch.find("appendToolLine(wrap, name, argument, frameOffset, frameLimit)") + append = branch.find( + "appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)" + ) arm = branch.find("armToolLineClock(wrap)") assert -1 < append < arm, ( "the arm follows the line's append β€” the baseline resets per line" diff --git a/tests/unit/test_chat_gate.py b/tests/unit/test_chat_gate.py index 2c7efdd..1eb93de 100644 --- a/tests/unit/test_chat_gate.py +++ b/tests/unit/test_chat_gate.py @@ -934,6 +934,40 @@ class _CannedLLM: yield StreamPiece("content", self.answer[i : i + 12]) +class _ToolCallCannedLLM(_CannedLLM): + """Streams ONE tool call (the given name + arguments) on the first + request, then the canned answer β€” the phase-146 (task 01) frame- + builder driver: the endpoint's ``ToolCallPiece`` branch (the + ``_pump`` loop in ``app.api.chat``) is the ONE extraction path + every ``tool`` frame goes through, so the scope guard is pinned + through the real endpoint (no parallel re-implementation).""" + + def __init__( + self, tool_name: str, arguments: dict[str, Any], answer: str = ANSWER + ) -> None: + super().__init__(answer=answer) + self.tool_name = tool_name + self.arguments = arguments + self._emitted = False + + async def chat_stream( + self, + messages: list[dict[str, str]], + tools: list[dict[str, Any]] | None = None, + scaffolding: ScaffoldingFilter | None = None, # phase 71 pass-through + ): + self.seen.append(messages) + self.seen_tools.append(tools) + if not self._emitted: + self._emitted = True + yield ToolCallPiece( + id="call_1", name=self.tool_name, arguments=self.arguments + ) + return + for i in range(0, len(self.answer), 12): + yield StreamPiece("content", self.answer[i : i + 12]) + + class _FakeSteeringResult: """Empty steering-note result (no stored notes in these unit tests).""" @@ -1324,6 +1358,133 @@ def test_endpoint_suggested_folder_lines_reach_the_high_prompt( ) +# ---------- endpoint: grep scope frame (phase 146, task 01) ---------- + + +def test_endpoint_scoped_grep_frame_carries_path( + client: TestClient, + gate_env: tuple[_FakeSession, _CannedLLM], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 146 (task 01): a SCOPED grep (``grep(pattern, path)``) + streams a ``tool`` frame that carries the scope β€” the re-searched + document's combined ``source/path`` β€” as the additive ``path`` + field, with ``argument`` still the model's ``pattern`` (the ONLY + case the field is present on the wire).""" + _session, _llm = gate_env + doc = _doc("The Ops Handbook", "page one\nthe grep me line\npage three") + monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) + llm = _ToolCallCannedLLM("grep", {"pattern": "grep me", "path": f"Homelab/{doc.path}"}) + fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm + monkeypatch.setattr( + rag_agent, + "find_document", + _find_docs({(doc.source, doc.path): doc}), + ) + + frames = _ask(client, "Where does the handbook mention grep me?") + + tools = [f for f in frames if f["type"] == "tool"] + assert tools == [ + { + "type": "tool", + "name": "grep", + "argument": "grep me", + "path": f"Homelab/{doc.path}", + } + ] + assert frames[-1]["type"] == "done" + assert frames[-1]["deflected"] is False + assert len(llm.seen) == 2 # the grep round + the answer round + + +def test_endpoint_bare_grep_frame_has_no_path_key( + client: TestClient, + gate_env: tuple[_FakeSession, _CannedLLM], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 146 (task 01): a bare grep's frame is byte-identical to + pre-phase β€” the ``path`` key is ABSENT (never ``null``) and no + other frame of the turn gains a key either.""" + _session, _llm = gate_env + doc = _doc("The Ops Handbook", "content") + monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) + llm = _ToolCallCannedLLM("grep", {"pattern": "grep me"}) + fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm + monkeypatch.setattr(rag_agent, "all_documents", lambda _db: []) + + frames = _ask(client, "Search the whole KB for grep me") + + tools = [f for f in frames if f["type"] == "tool"] + assert tools == [{"type": "tool", "name": "grep", "argument": "grep me"}] + assert all("path" not in f for f in frames) + assert frames[-1]["type"] == "done" + + +def test_endpoint_grep_non_string_path_scope_omitted( + client: TestClient, + gate_env: tuple[_FakeSession, _CannedLLM], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 146 (task 01): a NON-string ``path`` (the model-error + shape β€” a list here; the agent refuses the call) never reaches the + frame: the builder's string guard yields ``None`` β†’ the key is + omitted, so the frame is the exact bare-grep shape.""" + _session, _llm = gate_env + doc = _doc("The Ops Handbook", "content") + monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) + llm = _ToolCallCannedLLM("grep", {"pattern": "grep me", "path": ["s", "d.md"]}) + fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm + monkeypatch.setattr(rag_agent, "all_documents", lambda _db: []) + + frames = _ask(client, "Search the whole KB for grep me") + + tools = [f for f in frames if f["type"] == "tool"] + assert tools == [{"type": "tool", "name": "grep", "argument": "grep me"}] + assert all("path" not in f for f in frames) + assert frames[-1]["type"] == "done" + + +def test_endpoint_read_and_ls_frames_never_carry_scope( + client: TestClient, + gate_env: tuple[_FakeSession, _CannedLLM], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Phase 146 (task 01): ``read`` and ``ls`` NEVER read a scope from + their arguments β€” even though their ``path`` argument IS a string + (the combined ``source/path`` / the source name), it rides + ``argument`` (the pre-phase wire shape) and the frame has no + ``path`` key.""" + _session, _llm = gate_env + doc = _doc("The Ops Handbook", "content") + monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)])) + monkeypatch.setattr( + rag_agent, + "find_document", + _find_docs({(doc.source, doc.path): doc}), + ) + monkeypatch.setattr(rag_agent, "list_source_names", lambda _db: []) + + # A plain read (its ``path`` argument is a string β€” argument, not + # scope; the frame stays the exact pre-phase shape). + llm = _ToolCallCannedLLM("read", {"path": f"Homelab/{doc.path}"}) + fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm + frames = _ask(client, "What does the handbook say?") + reads = [f for f in frames if f["type"] == "tool"] + assert reads == [{"type": "tool", "name": "read", "argument": f"Homelab/{doc.path}"}] + assert all("path" not in f for f in frames) + assert frames[-1]["type"] == "done" + + # A scoped ls (same β€” the argument is the source-name string). + llm = _ToolCallCannedLLM("ls", {"path": "Homelab"}) + fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm + frames = _ask(client, "List my sources") + lss = [f for f in frames if f["type"] == "tool"] + assert lss == [{"type": "tool", "name": "ls", "argument": "Homelab"}] + assert all("path" not in f for f in frames) + assert frames[-1]["type"] == "done" + + # ---------- endpoint: KB overview row (phase 31) ---------- diff --git a/tests/unit/test_frontend_tool_states.py b/tests/unit/test_frontend_tool_states.py index 69430f6..d4647fd 100644 --- a/tests/unit/test_frontend_tool_states.py +++ b/tests/unit/test_frontend_tool_states.py @@ -62,7 +62,10 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None: assert "uiState === UI_STATE.thinking" in branch, ( "label updates only while the state is still thinking" ) - assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch + assert ( + "appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)" + in branch + ) # No setUiState in the branch: the state stays "thinking" (never stale). assert "setUiState" not in branch, ( "the tool branch must keep uiState=thinking (button stays disabled)" @@ -98,9 +101,15 @@ def test_calling_tool_label_strings() -> None: "the read status requires the name (new or legacy) AND a string argument" ) # The search status β€” new + legacy name, sitting BETWEEN the read - # branch and the listing fallback in the ternary. + # branch and the listing fallback in the ternary. Phase 146 + # (task 02): the label gains the additive scope β€” `` in `` + # ONLY when the frame carried one (a bare grep renders the exact + # pre-phase label β€” the template's empty-string fallback). assert 'name === "grep" || name === "search_documents") && argument' in branch - assert "`${brand()} is searching for ${argument}`" in branch + assert ( + "`${brand()} is searching for ${argument}" + "${framePath ? ` in ${framePath}` : \"\"}`" in branch + ) # Phase 70: the scoped ls status mirrors the scoped tool line; the # unscoped listing stays the final fallback. assert 'name === "ls" && argument' in branch @@ -227,9 +236,11 @@ def test_tool_lines_re_render_on_restore() -> None: end = js.find("function restoreConversation") body = js[fn:end] assert "Array.isArray(m.tools)" in body - assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, ( - "phase 139: the stored range rides the SAME helper β€” a pre-phase" - " record (no fields) renders byte-identical" + assert ( + "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body + ), ( + "phase 139: the stored range + the phase-146 stored scope ride the " + "SAME helper β€” a pre-phase record (no fields) renders byte-identical" ) thinking_restore = body.find("if (m.thinking)") tools_restore = body.find("Array.isArray(m.tools)") diff --git a/tests/unit/test_read_range_tool_lines.py b/tests/unit/test_read_range_tool_lines.py index 6c09874..910db5b 100644 --- a/tests/unit/test_read_range_tool_lines.py +++ b/tests/unit/test_read_range_tool_lines.py @@ -113,12 +113,15 @@ def test_read_range_suffix_shape_order() -> None: def test_append_tool_line_signature_takes_optional_range() -> None: """appendToolLine gains the additive ``offset``/``limit`` params (default ``null`` β€” a pre-phase 3-arg call renders byte-identical), - and the read arm renders the suffix through the SAME helper.""" + and the read arm renders the suffix through the SAME helper. + Phase 146 (task 02) extends the signature one more additive param + (``scope = null`` β€” the scoped grep's document) β€” the 5-arg + pre-phase-146 call shape renders byte-identically.""" body = _extract(_js(), "appendToolLine") assert ( - "function appendToolLine(wrap, name, argument, offset = null, limit = null)" - in _js() - ), "the signature carries the additive offset/limit (default null)" + "function appendToolLine(wrap, name, argument, offset = null, limit = null, " + "scope = null)" in _js() + ), "the signature carries the additive offset/limit/scope (default null)" assert "readRangeSuffix(offset, limit)" in body, ( "the read arm renders the suffix through the SAME helper" ) @@ -185,15 +188,21 @@ def test_live_tool_branch_reads_the_range_with_the_guard() -> None: "limit joins the record ONLY when non-null" ) assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded" - # Both values pass to the SAME helper (the line + the status label). - assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch + # Phase 146 (task 02): the scope joins the SAME two pass-throughs. + assert ( + "appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)" + in branch + ) def test_live_read_status_carries_the_range_suffix() -> None: """ASSUMPTION A2: the read status label carries the SAME requested-range suffix as the line (``readRangeSuffix`` β€” the single source of truth); a non-range read renders byte-identical (the - helper returns ``""``). The grep / ls labels are untouched.""" + helper returns ``""``). The ls labels are untouched; the grep label + gains the phase-146 additive scope (`` in `` ONLY when the + frame carried one β€” a bare grep renders the exact pre-phase label). + """ js = _js() tool_idx = js.find('ev.type === "tool"') delta_idx = js.find('ev.type === "delta"') @@ -202,8 +211,12 @@ def test_live_read_status_carries_the_range_suffix() -> None: "`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`" in branch ), "the read status carries the suffix (A2)" - # The other labels stay byte-identical (no suffix). - assert "`${brand()} is searching for ${argument}`" in branch + # The other labels: the grep scope extension (phase 146) + the + # ls labels untouched. + assert ( + "`${brand()} is searching for ${argument}" + "${framePath ? ` in ${framePath}` : \"\"}`" in branch + ) assert "`${brand()} is listing documents in ${argument}`" in branch assert "`${brand()} is listing documents`" in branch @@ -230,8 +243,8 @@ def test_restore_path_passes_the_stored_range_through() -> None: assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body, ( "the stored limit reads through the house guard" ) - assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, ( - "the stored range rides the SAME helper" + assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body, ( + "the stored range + the phase-146 stored scope ride the SAME helper" ) diff --git a/tests/unit/test_read_truncation_frontend.py b/tests/unit/test_read_truncation_frontend.py index f024f1b..5a7b3a1 100644 --- a/tests/unit/test_read_truncation_frontend.py +++ b/tests/unit/test_read_truncation_frontend.py @@ -163,7 +163,7 @@ def test_restore_path_renders_the_stored_marker() -> None: assert fn != -1 end = js.find("function restoreConversation") body = js[fn:end] - assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body + assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body assert "t.truncated && arg" in body, ( "only an argument-bearing (Reading) record with the flag renders" " the marker" @@ -173,7 +173,7 @@ def test_restore_path_renders_the_stored_marker() -> None: " Number(t.chars_total) || 0)" in body ) # The marker append sits INSIDE the tools loop, after the line append. - line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)") + line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)") note_idx = body.find("appendTruncatedNote(wrap, arg,") assert -1 < line_idx < note_idx diff --git a/tests/unit/test_search_tool_scope.py b/tests/unit/test_search_tool_scope.py new file mode 100644 index 0000000..bf59409 --- /dev/null +++ b/tests/unit/test_search_tool_scope.py @@ -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 `` 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 ```` +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 + ```` β€” 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 + ```` child β€” the pinned ``πŸ”Ž Searching for `` + ```` + 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 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 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 in `` β€” the scope rides + the template's additive `` in `` 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 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 pattern``, a + single ``code`` child, NO ``.grep-scope`` span; a SCOPED grep + appends the ``.grep-scope`` span AFTER the pattern ```` + holding the `` in `` text node + the second ```` (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 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 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 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"] diff --git a/tests/unit/test_sse_events.py b/tests/unit/test_sse_events.py index cdb4c8e..4c51933 100644 --- a/tests/unit/test_sse_events.py +++ b/tests/unit/test_sse_events.py @@ -167,6 +167,69 @@ def test_tool_event_range_fields_absent_not_null_when_omitted() -> None: assert set(dumped.keys()) == {"type", "name", "argument"}, dumped +def test_tool_frame_scoped_grep_carries_path() -> None: + """Phase 146 (task 01): a SCOPED grep's frame carries the scope β€” + the re-searched document's combined ``source/path`` β€” as the + additive ``path`` field (the ONLY case the field is present on the + wire), AFTER ``argument`` (the model's ``pattern``).""" + frame = sse_event( + ChatToolEvent(name="grep", argument="pattern", path="srcs/doc.md").model_dump() + ) + assert frame == ( + 'data: {"type": "tool", "name": "grep", "argument": "pattern", ' + '"path": "srcs/doc.md"}\n\n' + ) + assert _payload(frame) == { + "type": "tool", + "name": "grep", + "argument": "pattern", + "path": "srcs/doc.md", + } + + +def test_tool_frame_bare_grep_has_no_path_key() -> None: + """Phase 146 (task 01): a bare grep's frame has NO ``path`` key at + all β€” ABSENT, never ``null`` (the omission pattern; a ``null`` value + would change the pre-phase wire bytes).""" + dumped = ChatToolEvent(name="grep", argument="pattern").model_dump() + assert "path" not in dumped + assert dumped == {"type": "tool", "name": "grep", "argument": "pattern"} + + +def test_tool_frame_non_scoped_tools_dump_the_exact_pre_phase_shape() -> None: + """Phase 146 (task 01): every frame that is not a scoped grep β€” + ``read`` with and without a range, ``ls`` with and without an + argument β€” dumps to the EXACT pre-phase dict (the equality pins: + byte-identity of the non-scoped frames is the point of the + additive-omission serializer). A non-string ``path`` never reaches + the model at all: the frame builder (``app.api.chat``) string-guards + it to ``None`` before construction (pinned at the builder level in + ``tests/unit/test_chat_gate.py``).""" + assert ( + ChatToolEvent(name="read", argument="S/p.md").model_dump() + == {"type": "tool", "name": "read", "argument": "S/p.md"} + ) + assert ( + ChatToolEvent(name="read", argument="S/p.md", offset=1500, limit=30).model_dump() + == { + "type": "tool", + "name": "read", + "argument": "S/p.md", + "offset": 1500, + "limit": 30, + } + ) + assert ChatToolEvent(name="ls").model_dump() == { + "type": "tool", + "name": "ls", + "argument": None, + } + assert ( + ChatToolEvent(name="ls", argument="Homelab").model_dump() + == {"type": "tool", "name": "ls", "argument": "Homelab"} + ) + + def test_retry_frame_serializes_exactly() -> None: """Phase 67: the ``retry`` frame is exactly ``{type: "retry", attempt: int, max_attempts: int}`` β€” a transient status the client's diff --git a/tests/unit/test_tool_call_compact.py b/tests/unit/test_tool_call_compact.py index 2defd39..e2ea2b3 100644 --- a/tests/unit/test_tool_call_compact.py +++ b/tests/unit/test_tool_call_compact.py @@ -136,10 +136,10 @@ def test_restore_path_folds_the_disclosure() -> None: fn = js.find("function renderStoredMessage") assert fn != -1 body = js[fn : js.find("function restoreConversation", fn)] - assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body + assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body assert "closeToolCalls(wrap)" in body assert ( - body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)") + body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)") < body.find("closeToolCalls(wrap)") ), "the fold sits AFTER the tool-line restore loop"