From 9e7262a5c856d48db928b568ccbd137093dfe6bf Mon Sep 17 00:00:00 2001 From: ducoterra Date: Wed, 30 Sep 2026 12:55:23 -0400 Subject: [PATCH] phase: 139_read_range_tool_lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All verification is complete and green. Phase 139's three tasks (01–03) were already implemented in prior passes; this was the final verification pass, and every gate holds. ## Phase 139 — final verification report **Verified in working tree (no code changes needed):** - `readRangeSuffix` single-source-of-truth helper in `app.js` + byte-identical parity copy in `shared.js` (EN-dash ` (lines N–M)`, `(from line N)`, `(first N lines)`, `Number.isInteger(x) && x >= 1` guard mirroring the server's `_whole_line_number`) - `appendToolLine(wrap, name, argument, offset = null, limit = null)` — `` sibling after ``, only when suffix non-empty; `shared.js` `addToolLines` parity - Live `tool` branch: guarded `ev.offset`/`ev.limit`, additive `toolAcc` records (non-range keeps pre-phase shape), status label carries the suffix (A2); restore path passes stored `t.offset`/`t.limit` through, phase-95 truncation note untouched - E2E: `SAME_FILE_TRIGGER` mock flow (same file read twice, offsets 10/20 then 40/15) + 3-test Playwright suite **Test / lint / coverage results:** - `uv run pytest` (unit + integration): **3283 passed, 9 skipped, 0 failed** - `uv run pytest --cov=app --cov-report=term-missing`: **TOTAL 99%** (>90% gate) - `uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov`: **3 passed** in isolation - `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings** **Completion criteria:** 1. Range read renders `(lines N–M)` live; two same-file reads show DIFFERENT ranges — ✅ (E2E test 1) 2. Plain read byte-identical; phase-87/117 pins + emoji guard pass unedited — ✅ (E2E test 2 + full suite) 3. Suffix survives save → restore on chat page + shared page — ✅ (E2E test 3) 4. Full suite green, coverage >90%, E2E isolated green, ruff + pyright clean — ✅ 5. Commit + phase move — left to the harness per executor rules (no `git add`/`commit` run) **Notable:** the missing pytest summary line on one run was a transient output-buffering artifact, confirmed by re-run; DB was already up and healthy. **Next pending phase:** `140_pictorial_pdf_pages` --- .../139_read_range_tool_lines/00_phase.md | 0 .../01_tool_line_range_suffix.md | 0 .../02_live_restore_persist.md | 0 .../03_unit_and_e2e.md | 0 ...139_read_range_tool_lines__00_phase.a1.err | 0 .../139_read_range_tool_lines__00_phase.a1.md | 26 + ...ead_range_tool_lines__00_phase.a1.validate | 117 +++ ...ol_lines__01_tool_line_range_suffix.a1.err | 0 ...ool_lines__01_tool_line_range_suffix.a1.md | 17 + ...nes__01_tool_line_range_suffix.a1.validate | 117 +++ ...tool_lines__02_live_restore_persist.a1.err | 0 ..._tool_lines__02_live_restore_persist.a1.md | 18 + ...lines__02_live_restore_persist.a1.validate | 117 +++ ...d_range_tool_lines__03_unit_and_e2e.a1.err | 0 ...ad_range_tool_lines__03_unit_and_e2e.a1.md | 18 + ...ge_tool_lines__03_unit_and_e2e.a1.validate | 117 +++ app/schemas.py | 16 + frontend/assets/app.js | 87 ++- frontend/assets/shared.js | 30 + tests/e2e/mock_llm.py | 213 ++++++ tests/e2e/test_read_range_tool_lines.py | 692 ++++++++++++++++++ tests/e2e/test_read_truncation_cap.py | 10 + tests/integration/test_chats_api.py | 13 + tests/unit/test_big_read_progress.py | 2 +- tests/unit/test_chat_message_failed.py | 12 +- tests/unit/test_frontend_brand.py | 5 +- tests/unit/test_frontend_tool_states.py | 18 +- tests/unit/test_read_range_tool_lines.py | 497 +++++++++++++ tests/unit/test_read_truncation_frontend.py | 4 +- tests/unit/test_schemas.py | 72 +- tests/unit/test_tool_call_compact.py | 4 +- 31 files changed, 2197 insertions(+), 25 deletions(-) rename .agents/phases/{todo => complete}/139_read_range_tool_lines/00_phase.md (100%) rename .agents/phases/{todo => complete}/139_read_range_tool_lines/01_tool_line_range_suffix.md (100%) rename .agents/phases/{todo => complete}/139_read_range_tool_lines/02_live_restore_persist.md (100%) rename .agents/phases/{todo => complete}/139_read_range_tool_lines/03_unit_and_e2e.md (100%) create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.err create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.md create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.validate create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.err create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.md create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.validate create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.err create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.md create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.validate create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.err create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.md create mode 100644 .agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.validate create mode 100644 tests/e2e/test_read_range_tool_lines.py create mode 100644 tests/unit/test_read_range_tool_lines.py diff --git a/.agents/phases/todo/139_read_range_tool_lines/00_phase.md b/.agents/phases/complete/139_read_range_tool_lines/00_phase.md similarity index 100% rename from .agents/phases/todo/139_read_range_tool_lines/00_phase.md rename to .agents/phases/complete/139_read_range_tool_lines/00_phase.md diff --git a/.agents/phases/todo/139_read_range_tool_lines/01_tool_line_range_suffix.md b/.agents/phases/complete/139_read_range_tool_lines/01_tool_line_range_suffix.md similarity index 100% rename from .agents/phases/todo/139_read_range_tool_lines/01_tool_line_range_suffix.md rename to .agents/phases/complete/139_read_range_tool_lines/01_tool_line_range_suffix.md diff --git a/.agents/phases/todo/139_read_range_tool_lines/02_live_restore_persist.md b/.agents/phases/complete/139_read_range_tool_lines/02_live_restore_persist.md similarity index 100% rename from .agents/phases/todo/139_read_range_tool_lines/02_live_restore_persist.md rename to .agents/phases/complete/139_read_range_tool_lines/02_live_restore_persist.md diff --git a/.agents/phases/todo/139_read_range_tool_lines/03_unit_and_e2e.md b/.agents/phases/complete/139_read_range_tool_lines/03_unit_and_e2e.md similarity index 100% rename from .agents/phases/todo/139_read_range_tool_lines/03_unit_and_e2e.md rename to .agents/phases/complete/139_read_range_tool_lines/03_unit_and_e2e.md diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.err b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.md b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.md new file mode 100644 index 0000000..b656012 --- /dev/null +++ b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.md @@ -0,0 +1,26 @@ +All verification is complete and green. Phase 139's three tasks (01–03) were already implemented in prior passes; this was the final verification pass, and every gate holds. + +## Phase 139 — final verification report + +**Verified in working tree (no code changes needed):** +- `readRangeSuffix` single-source-of-truth helper in `app.js` + byte-identical parity copy in `shared.js` (EN-dash ` (lines N–M)`, `(from line N)`, `(first N lines)`, `Number.isInteger(x) && x >= 1` guard mirroring the server's `_whole_line_number`) +- `appendToolLine(wrap, name, argument, offset = null, limit = null)` — `` sibling after ``, only when suffix non-empty; `shared.js` `addToolLines` parity +- Live `tool` branch: guarded `ev.offset`/`ev.limit`, additive `toolAcc` records (non-range keeps pre-phase shape), status label carries the suffix (A2); restore path passes stored `t.offset`/`t.limit` through, phase-95 truncation note untouched +- E2E: `SAME_FILE_TRIGGER` mock flow (same file read twice, offsets 10/20 then 40/15) + 3-test Playwright suite + +**Test / lint / coverage results:** +- `uv run pytest` (unit + integration): **3283 passed, 9 skipped, 0 failed** +- `uv run pytest --cov=app --cov-report=term-missing`: **TOTAL 99%** (>90% gate) +- `uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov`: **3 passed** in isolation +- `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings** + +**Completion criteria:** +1. Range read renders `(lines N–M)` live; two same-file reads show DIFFERENT ranges — ✅ (E2E test 1) +2. Plain read byte-identical; phase-87/117 pins + emoji guard pass unedited — ✅ (E2E test 2 + full suite) +3. Suffix survives save → restore on chat page + shared page — ✅ (E2E test 3) +4. Full suite green, coverage >90%, E2E isolated green, ruff + pyright clean — ✅ +5. Commit + phase move — left to the harness per executor rules (no `git add`/`commit` run) + +**Notable:** the missing pytest summary line on one run was a transient output-buffering artifact, confirmed by re-run; DB was already up and healthy. + +**Next pending phase:** `140_pictorial_pdf_pages` diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.validate b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.validate new file mode 100644 index 0000000..4081b10 --- /dev/null +++ b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__00_phase.a1.validate @@ -0,0 +1,117 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +...........................................................s.....sss...s [ 54%] +s....................................................................... [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 96%] +...........sss.......................................................... [ 98%] +.................................................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 268 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 179 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 256 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 412 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 56 0 100% +app/rag/importer.py 671 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 119 0 100% +app/rag/prompts.py 154 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 49 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5432 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/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.err b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.md b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.md new file mode 100644 index 0000000..06ad5f0 --- /dev/null +++ b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.md @@ -0,0 +1,17 @@ +Everything is green. Task 01 is complete. + +## Report + +**Implemented (task 01 — range suffix on the Reading line):** +- `frontend/assets/app.js`: new `readRangeSuffix(offset, limit)` helper (next to `appendToolLine`) — guard `Number.isInteger(x) && x >= 1` mirroring the server's `_whole_line_number`; both → ` (lines ${offset}–${offset + limit - 1})` (EN-dash), offset only → ` (from line ${offset})`, limit only → ` (first ${limit} lines)`, else `""` +- `appendToolLine(wrap, name, argument, offset = null, limit = null)`: read arm (`read`/`read_document`) appends `` (textContent, span SIBLING after `` — phase-95 precedent) only when the suffix is non-empty; other arms and pinned literals untouched, no new emoji +- `frontend/assets/shared.js`: parity copy of `readRangeSuffix` + identical span in `addToolLines`'s read arm (keyed on the saved record's `t.offset`/`t.limit`) — required since `shared.html` loads only `shared.js` + +**Verification:** +- Node repro: 46/46 PASS — completion criterion `offset=42, limit=30` → `📄 Reading reese:notes/git.md (lines 42–71)`; all three suffix shapes; malformed guards (0/float/bool/string); grep/ls arms ignore range args; plain-read + every other line **byte-identical to git HEAD** (structural fingerprint); shared-page parity + field-less records byte-identical; span order with the truncation note = code → read-range → truncated-note +- `uv run pytest --cov=app --cov-report=term` → 3268 passed, 9 skipped; TOTAL **99%** (>90%) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings + +**Notable decisions:** partially-malformed input falls through to the shape priority (bad offset + valid limit → ` (first N lines)`); no CSS added (task scope — no other-arm changes); unit test file is task 03's deliverable per the task's "Unit: covered by task 03's file"; no app/ code changes; nothing committed (left in working tree). + +**Next pending task:** `02_live_restore_persist.md` (live `ev.offset`/`ev.limit`, `toolAcc` persistence, restore path, status label) diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.validate b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.validate new file mode 100644 index 0000000..5ebbbc4 --- /dev/null +++ b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__01_tool_line_range_suffix.a1.validate @@ -0,0 +1,117 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +...........................................................s.....sss...s [ 54%] +s....................................................................... [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +....................................................................sss. [ 96%] +........................................................................ [ 98%] +..................................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 268 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 179 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 256 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 412 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 56 0 100% +app/rag/importer.py 671 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 119 0 100% +app/rag/prompts.py 154 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 49 0 100% +app/schemas.py 422 0 100% +-------------------------------------------------- +TOTAL 5430 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/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.err b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.md b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.md new file mode 100644 index 0000000..ed7f157 --- /dev/null +++ b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.md @@ -0,0 +1,18 @@ +All gates green. Task 02 is complete. + +**Implemented (task 02 — live frames, persistence, restore):** +- `frontend/assets/app.js` live `tool` branch: `ev.offset`/`ev.limit` read with the `Number.isInteger(x) && x >= 1` guard → `null` when absent/malformed; `toolAcc` record gains each field ONLY when non-null (non-range records keep the exact `{name, argument}` shape); both passed to `appendToolLine` +- Status label: `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}` (ASSUMPTION A2; grep/ls labels untouched) +- Restore path (`renderStoredMessage`): guarded `t.offset`/`t.limit` → `appendToolLine(wrap, t.name, arg, tOffset, tLimit)`; phase-95 truncation note untouched +- `app/schemas.py` `ToolCall`: additive `offset`/`limit` (`int | None`, `ge=1`, defaults) — required so the server save→restore round-trip retains the range (pydantic otherwise silently drops them); phase-95 truncated-fields precedent, no migration +- Save-payload sites (done/failed/stopped/pagehide) needed no code change — they serialize `toolAcc` as-is; phase-83 cap tests unedited + +**Test / lint / coverage:** +- `uv run pytest --cov=app --cov-report=term-missing` → 3270 passed, 9 skipped; TOTAL 99% (>90%) +- `uv run ruff check . && uv run pyright` → clean; 0 errors, 0 warnings +- `node --check` on both JS files; node smoke: `(lines 42–71)` / `(from line 7)` / `(first 5 lines)` shapes, plain read byte-identical, malformed → no suffix +- E2E spot checks in isolation: `test_read_truncation_cap.py` 3/3, `test_agent_document_tools.py`+`test_read_line_ranges.py` 6/6, `test_tool_call_compact.py`+`test_harness_aligned_tools.py`+`test_share_chat.py` 11/11 + +**Decisions:** re-pinned 5 superseded source pins (tool-branch push/label/append calls) + updated phase-95/120/131 full-shape fixtures for the additive fields (house pattern). No commit made (harness owns it). + +**Next pending task:** `03_unit_and_e2e.md` (dedicated unit file + node behavior + mock flow + Playwright E2E). diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.validate b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.validate new file mode 100644 index 0000000..ea6ac7e --- /dev/null +++ b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__02_live_restore_persist.a1.validate @@ -0,0 +1,117 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 35%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 46%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +...........................................................s.....sss...s [ 54%] +s....................................................................... [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +......................................................................ss [ 96%] +s....................................................................... [ 98%] +....................................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 268 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 179 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 256 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 412 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 56 0 100% +app/rag/importer.py 671 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 119 0 100% +app/rag/prompts.py 154 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 49 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5432 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/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.err b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.md b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.md new file mode 100644 index 0000000..fbdab00 --- /dev/null +++ b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.md @@ -0,0 +1,18 @@ +All work is complete and green. Here is my report. + +## Phase 139 · Task 03 — complete + +**Implemented** +- `tests/e2e/mock_llm.py` — ONE new scripted flow (`SAME_FILE_TRIGGER` = "read the same file twice"): range-reads the SAME doc twice (offset 10/limit 20, then 40/limit 15) and echoes both ranges (phase-131 read-echo shape); module-docstring entry + disjoint trigger. +- `tests/e2e/test_read_range_tool_lines.py` (new, isolated, mock-only) — 300-line KB doc, cap set between the two slice lengths; (a) two Reading lines, same `` path, distinct suffixes `(lines 10–29)`/`(lines 40–54)`; (b) plain read → no `.read-range` span; (c) auto-save → `/?chat=` → suffixes re-render. +- `tests/unit/test_read_range_tool_lines.py` (new) — source pins (suffix literals, `Number.isInteger` guard, live `ev.offset/limit` read, `toolAcc` stamp, restore pass-through, shared.js parity) + node behavior (3 suffix shapes, plain-line byte-identity, shared-page parity). + +**Results** +- `uv run pytest tests/unit/test_read_range_tool_lines.py -v` → 13 passed +- `uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov` → 3 passed (isolated, stable ×2) +- `uv run pytest --cov=app` → 3283 passed, 0 failed, TOTAL **99%** (>90%); `ruff check . && pyright` → clean +- Neighbor E2E (`test_read_line_ranges.py`, `test_read_truncation_cap.py`) → 5 passed (no mock regression) + +**Notable decision:** the cap forces the first (20-line) range read to truncate — load-bearing, since the agent refuses a second range read of a non-truncated doc (`ALREADY_IN_CONTEXT`); truncation puts the doc in the truncated set so the second read proceeds as a real, distinct range. + +**Next pending task:** `.agents/phases/todo/140_pictorial_pdf_pages/01_settings_and_gate.md` (phase 139's 03 is the last of its tasks). diff --git a/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.validate b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.validate new file mode 100644 index 0000000..4081b10 --- /dev/null +++ b/.agents/reports/139_read_range_tool_lines/139_read_range_tool_lines__03_unit_and_e2e.a1.validate @@ -0,0 +1,117 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 13%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 24%] +........................................................................ [ 26%] +........................................................................ [ 28%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 37%] +........................................................................ [ 39%] +........................................................................ [ 41%] +........................................................................ [ 43%] +........................................................................ [ 45%] +........................................................................ [ 48%] +........................................................................ [ 50%] +........................................................................ [ 52%] +...........................................................s.....sss...s [ 54%] +s....................................................................... [ 56%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 63%] +........................................................................ [ 65%] +........................................................................ [ 67%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 78%] +........................................................................ [ 80%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 96%] +...........sss.......................................................... [ 98%] +.................................................... [100%] +=============================== warnings summary =============================== +.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1 + /var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 268 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 179 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 256 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 412 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 56 0 100% +app/rag/importer.py 671 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 119 0 100% +app/rag/prompts.py 154 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 49 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5432 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/app/schemas.py b/app/schemas.py index cc69089..6a43e37 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -978,6 +978,20 @@ class ToolCall(BaseModel): is JSON) — a saved chat written before phase 95 (no fields) validates UNCHANGED (the phase-50 backward-compat rule) and renders without the marker. + + Phase 139 (task 02): the read's REQUESTED line range (phase 131's + ``offset`` / ``limit`` wire fields) rides the SAME record so a saved + chat re-renders the "(lines N–M)" suffix on the Reading line after + a save → restore round-trip (the chat page's restore path and the + shared page both read it off the stored row). Small additive fields + with defaults, no migration (the phase-95 truncated-fields + precedent): both are ABSENT (``None``) on every non-range record — + the UI includes a field only when well-formed — and both carry the + same >= 1 whole-line bound as the server's ``_whole_line_number`` + (``app/api/chat.py``; the UI's guard mirrors it, so a value that + 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). """ name: str = Field(max_length=100) @@ -985,6 +999,8 @@ class ToolCall(BaseModel): truncated: bool = False chars_shown: int | None = Field(default=None, ge=0) 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) #: One suggestion chip (phase 83, A1): a short deterministic string — diff --git a/frontend/assets/app.js b/frontend/assets/app.js index 83a2133..550569c 100644 --- a/frontend/assets/app.js +++ b/frontend/assets/app.js @@ -1116,7 +1116,7 @@ function closeToolCalls(wrap) { * 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) { +function appendToolLine(wrap, name, argument, offset = null, limit = null) { const body = wrap?.querySelector?.(".msg-body"); if (!body) return; // Phase 117 (D2): the lines ride in a native
disclosure — @@ -1150,6 +1150,19 @@ function appendToolLine(wrap, name, argument) { const code = document.createElement("code"); code.textContent = argument; // the path is data, never markup line.appendChild(code); + // Phase 139 (task 01): the requested line range — a span SIBLING + // after the child (the phase-95 truncation-note precedent: + // append a span, never rewrite the pinned template text), appended + // ONLY when the suffix is non-empty — a plain read's line stays + // byte-identical to pre-phase (the phase-87/117 pins and the + // frontend emoji guard stay green; no new emoji). + const rangeSuffix = readRangeSuffix(offset, limit); + if (rangeSuffix) { + const range = document.createElement("span"); + range.className = "read-range"; + range.textContent = rangeSuffix; // the range is data, never markup + line.appendChild(range); + } } else if ((name === "grep" || name === "search_documents") && argument) { line.textContent = "🔎 Searching for "; const code = document.createElement("code"); @@ -1171,6 +1184,30 @@ function appendToolLine(wrap, name, argument) { `Tool call${n === 1 ? "" : "s"} (${n})`; } +/* Phase 139 (task 01): the requested line range on the Reading line — + * the owner's TODO: repeated reads show "the same file over and over" + * with no way to tell which lines each read covered. A range read + * (read(path, offset, limit), phase 131) renders WHICH lines were + * requested, so two reads of one file are distinguishable. The single + * source of truth for the wording — the tool line and the status label + * both render it (ASSUMPTION A2). The well-formedness guard mirrors the + * server's _whole_line_number (app/api/chat.py): Number.isInteger(x) && + * x >= 1 (bools are not integers in JS — the house guard, as on the + * server) — a malformed value renders no suffix. The range shown is the + * model's REQUESTED extent (ASSUMPTION A1): offset–(offset + limit − 1) + * with the en-dash per the pinned copy — the UI cannot know the document + * length, so a limit past EOF renders the requested extent. Neither + * field well-formed → "" — no span, no bytes added, the plain line stays + * byte-identical to pre-phase. */ +function readRangeSuffix(offset, limit) { + const offOk = Number.isInteger(offset) && offset >= 1; + const limOk = Number.isInteger(limit) && limit >= 1; + if (offOk && limOk) return ` (lines ${offset}–${offset + limit - 1})`; + if (offOk) return ` (from line ${offset})`; + if (limOk) return ` (first ${limit} lines)`; + return ""; +} + /* Phase 95 (task 02): the truncation marker on a Reading line. The * `tool_result` frame's argument is the model's raw `source/path` — the * SAME string the matching `tool` frame put in the line's `` child @@ -2035,7 +2072,18 @@ function renderStoredMessage(m) { if (!t || typeof t.name !== "string") continue; const arg = typeof t.argument === "string" && t.argument ? t.argument : null; - appendToolLine(wrap, t.name, arg); + // Phase 139 (task 02): a saved range read (the live toolAcc + // stamp — additive on the bor.chat.v1 record, the phase-95 + // truncated-fields precedent) re-renders the SAME suffix through + // the SAME helper: the same Number.isInteger guard → a record + // saved before the phase (no fields) renders byte-identically + // (the phase-95 truncation note is untouched — it keys on the + // argument, not the range). + const tOffset = + 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); if (t.truncated && arg) { appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0); } @@ -2991,7 +3039,9 @@ async function runTurn(text, { reask = false, image = null } = {}) { let sawThinking = false; // did any `thinking` frame arrive this turn? let sawDone = false; // did the stream end with a `done` event? let toolAcc = []; // phase 37: {name, argument} per `tool` frame — - // persisted with the turn (optional `tools` key) + // persisted with the turn (optional `tools` key); + // phase 139: a range read's {offset, limit} join + // additively (each field only when well-formed) try { // thinking = pre-token: dots + busy button. The guard is armed so a @@ -3113,16 +3163,39 @@ async function runTurn(text, { reask = false, image = null } = {}) { const name = typeof ev.name === "string" ? ev.name : ""; const argument = typeof ev.argument === "string" && ev.argument ? ev.argument : null; - toolAcc.push({ name, argument }); + // Phase 139 (task 02): the phase-131 wire fields — the `tool` + // frame already carries offset/limit for a range read (ABSENT + // on every non-range frame, never null — the server's + // _whole_line_number drops them when malformed). The SAME + // well-formedness guard as readRangeSuffix (Number.isInteger(x) + // && x >= 1) → null when absent / malformed, and each field + // lands on the record ONLY when non-null: a non-range frame + // keeps the exact pre-phase {name, argument} shape (the + // bor.chat.v1 save JSONB is additive — old records restore + // byte-identically, no migration), while a range read persists + // WHICH lines the model requested (the owner's "same file over + // and over" confusion — two reads of one file now differ). + const frameOffset = + Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null; + const frameLimit = + Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null; + const toolRecord = { name, argument }; + if (frameOffset !== null) toolRecord.offset = frameOffset; + if (frameLimit !== null) toolRecord.limit = frameLimit; + toolAcc.push(toolRecord); clearTurnTimeout(); // the stream is alive — a frame arrived if (!wrap) wrap = addMessage("brain", ""); // Phase 70: the harness-aligned names (read/grep/ls) map to the // same status copy as their legacy counterparts (read_document / // search_documents) — a pre-remap frame keeps its label; the - // scoped ls mirrors the scoped tool line. + // scoped ls mirrors the scoped tool line. Phase 139 (task 02, + // ASSUMPTION A2): the read status carries the SAME requested- + // range suffix as the line (readRangeSuffix — the single source + // of truth for the wording; a non-range read renders + // byte-identical, the helper returns ""). const toolStatus = (name === "read" || name === "read_document") && argument - ? `${brand()} is reading ${argument}` + ? `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}` : (name === "grep" || name === "search_documents") && argument ? `${brand()} is searching for ${argument}` : name === "ls" && argument @@ -3134,7 +3207,7 @@ async function runTurn(text, { reask = false, image = null } = {}) { .querySelector("#typing-indicator .bubble") ?.setAttribute("aria-label", toolStatus); } - appendToolLine(wrap, name, argument); + appendToolLine(wrap, name, argument, frameOffset, frameLimit); // 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/frontend/assets/shared.js b/frontend/assets/shared.js index 447bf9c..1e13c08 100644 --- a/frontend/assets/shared.js +++ b/frontend/assets/shared.js @@ -203,6 +203,17 @@ function addToolLines(wrap, tools) { const code = document.createElement("code"); code.textContent = argument; // the path is data, never markup line.appendChild(code); + // Phase 139 (task 01): the SAME requested-range suffix as the chat + // page's appendToolLine (the parity contract) — a span SIBLING + // after the child, appended ONLY when the suffix is + // non-empty: a record without the fields renders exactly as today. + const rangeSuffix = readRangeSuffix(t.offset, t.limit); + if (rangeSuffix) { + const range = document.createElement("span"); + range.className = "read-range"; + range.textContent = rangeSuffix; // the range is data, never markup + line.appendChild(range); + } } else if ( (t.name === "grep" || t.name === "search_documents") && argument ) { @@ -236,6 +247,25 @@ function addToolLines(wrap, tools) { body.insertBefore(disclosure, body.querySelector(".bubble")); } +/* Phase 139 (task 01): the shared page's parity copy of app.js's + * readRangeSuffix — the requested line range on the Reading line. The + * saved record now carries the chat page's offset/limit (the toolAcc + * stamp, phase 139); a record saved before the phase (no fields) renders + * exactly as today (no migration). IDENTICAL literals and IDENTICAL + * Number.isInteger guard (mirrors the server's _whole_line_number, + * app/api/chat.py) — the shared page re-renders saved records + * pixel-identically to the chat page's restore path (the phase-50 + * contract). The range shown is the model's REQUESTED extent (the + * ASSUMPTION A1 pin in app.js's readRangeSuffix). */ +function readRangeSuffix(offset, limit) { + const offOk = Number.isInteger(offset) && offset >= 1; + const limOk = Number.isInteger(limit) && limit >= 1; + if (offOk && limOk) return ` (lines ${offset}–${offset + limit - 1})`; + if (offOk) return ` (from line ${offset})`; + if (limOk) return ` (first ${limit} lines)`; + return ""; +} + /* "Maybe try:" chips under a deflected bubble (honesty gate, phase * 04) — PLAIN SPAN text, not buttons (owner-locked 2026-08-29: a * guest tapping a chip has nowhere to go — zero interactive diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index a56e344..8e44c6c 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -603,6 +603,49 @@ Implements just enough of the aipi surface: never hit it); verified 2026-09-28: no existing E2E question or fixture file contains the phrase, so every other suite is unaffected. + - user message containing ``read the same file twice`` + (``SAME_FILE_TRIGGER``, phase 139 task 03 — the read + range-tool-lines' dedicated story suite + ``tests/e2e/test_read_range_tool_lines.py``) **and** the system + prompt carries the ```` section -> the owner's exact + "same file over and over" scenario (TODO.md L4), end to end: the + model range-reads the SAME document TWICE with different + ``offset``/``limit``, so the two Reading lines are distinguishable. + The question carries its own target after the colon (``read the + same file twice: read source/path`` — the question-carried-call + convention of ``READ_CAP_TRIGGER`` / ``DRILL_TRIGGER``), and the + step is then discriminated statelessly by the tool results in the + messages (the house count pattern — streaming only): + * request 1 (``tools`` offered, no read results yet): a + ``tool_calls`` delta — ``read`` on the parsed target with + ``offset`` = ``SAME_FILE_FIRST_OFFSET`` (10) and ``limit`` = + ``SAME_FILE_FIRST_LIMIT`` (20) (id ``call_0``); + * a ``tool``-role read result is in the messages (the FIRST read + landed): the SECOND range read — a ``tool_calls`` delta — + ``read`` on the SAME target with ``offset`` = + ``SAME_FILE_SECOND_OFFSET`` (40) and ``limit`` = + ``SAME_FILE_SECOND_LIMIT`` (15) (id ``call_1``) — the same + file again, a different range; + * TWO or more ``tool``-role read results are in the messages + (both range reads landed): the forced answer, byte-stable, in + the phase-131 read-echo shape — ``Read the same file twice — + lines 10–29: ; lines 40–54: `` where each ```` is + that read result's content with the header lines (the + phase-106 D5 ``date:`` line + the phase-131 ``lines: a-b of + N`` extent) skipped, first 80 chars (the phase-94 read-echo + convention — BOTH ranges reached the model's context). NOTE: + for the second read to PROCEED (not be refused + ``ALREADY_IN_CONTEXT``) the document must already be in the + agent's truncated set — the story suite boots the app under + test with a ``BOR_READ_MAX_CHARS`` small enough that the + FIRST (20-line) range slice truncates, which is exactly the + large-document premise of the owner's confusion. + Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger + phrases — the phase-71/72/94/118/131 ordering convention; the + trigger needs the ```` section, so deflected turns never + hit it); verified 2026-09-30: no existing E2E question or + fixture file contains the phrase, so every other suite is + unaffected. Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic dead-endpoint behavior for the retry E2E suite (``tests/e2e/ @@ -2283,6 +2326,121 @@ def _read_lines_flow(body: dict[str, Any]) -> tuple[str, ...] | None: return ("grep",) +# --------------------------------------------------------------------------- +# Phase 139 (task 03): the owner's "same file over and over" scenario — +# the model range-reads the SAME document TWICE with different +# offset/limit, so the two Reading lines are distinguishable. See the +# module docstring. +# --------------------------------------------------------------------------- + +#: A user message containing this substring (case-insensitive) — +#: combined with the ```` section in the system prompt — drives +#: the deterministic SAME-FILE-TWICE flow (the read range-tool-lines' +#: story suite, ``tests/e2e/test_read_range_tool_lines.py`` — the +#: owner's "same file over and over" confusion, TODO.md L4). Checked +#: BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger phrases — +#: the phase-71/72/94/118/131 ordering convention); verified +#: 2026-09-30: no existing E2E question or fixture file contains the +#: phrase, so every other suite is unaffected. +SAME_FILE_TRIGGER = "read the same file twice" + +#: The scripted target in the same-file-twice question (case- +#: insensitive — the suite's question capitalizes the trigger's first +#: letter): the verb (``read``) plus the target — a combined +#: ``source/path``, parsed from the RAW user message so the target +#: keeps its case. The target is a ``[a-z0-9_./-]`` run (case- +#: insensitively), so a `` — `` flavor separator can never bleed into +#: it (the read-cap / drill-down convention, ``_READ_CAP_CALL_RE`` / +#: ``_DRILL_CALL_RE``). +_SAME_FILE_CALL_RE = re.compile( + r"read the same file twice:\s*read\s+(?P[a-z0-9_./-]+)", + re.I, +) + +#: The FIRST range read's requested extent — the model's REQUEST +#: (ASSUMPTION A1: the suffix shows the request, not the actual slice +#: extent). ``offset`` 10 + ``limit`` 20 renders `` (lines 10–29)``. +SAME_FILE_FIRST_OFFSET = 10 +SAME_FILE_FIRST_LIMIT = 20 + +#: The SECOND range read's requested extent — the SAME file again, a +#: different range (the owner's confusion made visible). ``offset`` 40 +#: + ``limit`` 15 renders `` (lines 40–54)``. +SAME_FILE_SECOND_OFFSET = 40 +SAME_FILE_SECOND_LIMIT = 15 + + +def _same_file_quote(doc_content: str) -> str: + """The phase-94/131 read-echo quote of ONE read result's content. + + The read result's header lines sit between the ``Document …:`` + header and the document text — the phase-106 D5 ``date:`` line + always, the phase-131 ``lines: a-b of N`` extent on a range read + (a plain read carries no extent line) — skipped (the drill-flow + convention) so the quote is the pure read content, first 80 chars + (the RANGE reached the model's context). Mirrors the phase-131 + ``_read_lines_flow`` quote logic so the two flows' echo shapes + stay byte-consistent. + """ + content = doc_content + if content.startswith("date: "): + content = content.partition("\n")[2] + if content.startswith("lines: "): + content = content.partition("\n")[2] + return content[:80] + + +def _same_file_flow(body: dict[str, Any]) -> tuple[str, ...] | None: + """Classify a phase-139 same-file-twice request (see the module + docstring). The question carries its own target (``read the same + file twice: read source/path``); the step is then discriminated + statelessly by the READ results in the messages (the house count + pattern — the flow only ever issues ``read`` calls, so counting + read results is reliable): + + * ``("answer", quote1, quote2)`` — TWO or more ``tool``-role read + results are in the messages (both range reads landed): the + forced answer, byte-stable, in the phase-131 read-echo shape — + ``Read the same file twice — lines 10–29: ; lines 40–54: + `` where each ```` is that read result's pure content + (header lines skipped, first 80 chars — the :func: + ``_same_file_quote`` convention). + * ``("read2", target)`` — EXACTLY ONE read result is in the + messages (the first read landed): the SECOND range read on the + SAME target — ``offset`` = :data:`SAME_FILE_SECOND_OFFSET`, + ``limit`` = :data:`SAME_FILE_SECOND_LIMIT` (id ``call_1``). + * ``("read1", target)`` — ``tools`` are offered and no read result + is in the messages yet: the FIRST range read on the parsed + target — ``offset`` = :data:`SAME_FILE_FIRST_OFFSET`, ``limit`` + = :data:`SAME_FILE_FIRST_LIMIT` (id ``call_0``). + * ``None`` — not the flow: the trigger is absent, the ```` + section is missing (deflected turns never carry it), the + target is unparseable, or ``tools`` are not offered and no read + result is in the messages yet (e.g. ``agent_max_rounds=0``). + """ + user = _user(body) + if SAME_FILE_TRIGGER not in user.lower(): + return None + if "" not in _system(body): + return None + match = _SAME_FILE_CALL_RE.search(user) + if match is None: + return None + target = match.group("arg") + reads = _read_results(body) + if len(reads) >= 2: + return ( + "answer", + _same_file_quote(reads[0][1]), + _same_file_quote(reads[1][1]), + ) + if len(reads) == 1: + return ("read2", target) + if not body.get("tools"): + return None + return ("read1", target) + + def long_answer() -> str: """~900-word deterministic walkthrough (phase 11): numbered steps plus a unique final line that must survive the stream untruncated.""" @@ -3525,6 +3683,61 @@ def chat_completions(body: dict[str, Any]) -> Any: media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) + # Phase 139 (task 03): the owner's "same file over and over" + # scenario (the read range-tool-lines' story suite, + # tests/e2e/test_read_range_tool_lines.py — TODO.md L4): the + # model range-reads the SAME document TWICE with different + # offset/limit, so the two Reading lines are distinguishable. + # Checked BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger + # phrase — the phase-71/72/94/118/131 ordering convention; the + # trigger needs the ```` section, so deflected turns + # never hit it). + same_file = _same_file_flow(body) + if same_file is not None: + if same_file[0] == "read1": + # The FIRST range read on the parsed target — the + # model's REQUEST (ASSUMPTION A1: the suffix shows the + # request, not the actual slice extent). + stream = _tool_call_stream( + "read", + { + "path": same_file[1], + "offset": SAME_FILE_FIRST_OFFSET, + "limit": SAME_FILE_FIRST_LIMIT, + }, + "call_0", + ) + elif same_file[0] == "read2": + # The SECOND range read — the SAME file again, a + # different range (the owner's confusion made visible). + stream = _tool_call_stream( + "read", + { + "path": same_file[1], + "offset": SAME_FILE_SECOND_OFFSET, + "limit": SAME_FILE_SECOND_LIMIT, + }, + "call_1", + ) + else: # "answer" — the forced read-echo of BOTH ranges + stream = _sse_stream( + _apply_max_tokens( + f"Read the same file twice — lines " + f"{SAME_FILE_FIRST_OFFSET}–" + f"{SAME_FILE_FIRST_OFFSET + SAME_FILE_FIRST_LIMIT - 1}: " + f"{same_file[1]}; lines " + f"{SAME_FILE_SECOND_OFFSET}–" + f"{SAME_FILE_SECOND_OFFSET + SAME_FILE_SECOND_LIMIT - 1}: " + f"{same_file[2]}", + body.get("max_tokens"), + ), + 0.0, + ) + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) # Phase 124 (task 07, image chips and answers, TODO L3): the # scripted SHOW_IMAGE flow — the mock shows the seeded image # document (the ```` block with the image- diff --git a/tests/e2e/test_read_range_tool_lines.py b/tests/e2e/test_read_range_tool_lines.py new file mode 100644 index 0000000..33abc63 --- /dev/null +++ b/tests/e2e/test_read_range_tool_lines.py @@ -0,0 +1,692 @@ +"""Phase 139 task 03 E2E (Playwright, mock-only): the Reading tool line +shows WHICH lines are being read — the owner's "same file over and over" +confusion, end to end. + +TODO.md L4: "The 'Reading' tool UI output doesn't show what lines are +being read, just the same file over and over which is confusing to the +user". When the agent range-reads a document (``read(path, offset, +limit)`` — phase 131), the Reading tool line now shows the REQUESTED +extent — ``📄 Reading source/path (lines 42–71)`` — so two +reads of the SAME file are distinguishable: live (the SSE ``tool`` +frame's ``offset``/``limit``), on save (the additive ``bor.chat.v1`` +record), and on restore (the ``/?chat=`` boot load + the shared +page's parity render). A plain read renders byte-identical to +pre-phase (no ``.read-range`` span). + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov + +MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — this story's +gate is the deterministic SCRIPTED flow in ``tests/e2e/mock_llm.py`` +(``SAME_FILE_TRIGGER``, the owner's "same file over and over" scenario): +the model range-reads the SAME document TWICE with different +``offset``/``limit`` (10/20 then 40/15), and the answer echoes both +ranges (the phase-131 read-echo shape). The plain-read half (test 2) +reuses the mock's EXISTING ``READ_CAP_TRIGGER`` flow (``read the +capped document: read source/path`` — a plain read, no range). + +The app under test boots with a SMALL ``BOR_READ_MAX_CHARS`` (the +env-override-for-the-app-under-test pattern, as in +``test_read_line_ranges.py``): the cap sits BETWEEN the two range +slices' lengths (the first 20-line slice truncates, the second 15-line +slice does not). That truncation is LOAD-BEARING, not incidental: the +agent refuses a second range read of a NON-truncated document that is +already in context (``ALREADY_IN_CONTEXT`` — the range is a subset of +the prompt). The first (truncated) range read puts the document in the +agent's truncated set, so the SECOND range read PROCEEDS and returns +its own slice — the owner's "same file over and over" with two real, +distinguishable ranges (the large-document premise of the confusion). + +KB fixture — ONE deterministic 300-line markdown document +(``samedoc/twice.md``): line 1 is a marker line (the grounding words +the questions share — the mock-calibrated gate stays GROUNDED, the +```` section is present, so both scripted flows fire); lines +2–300 are fixed-length filler (``Line : …`` — the line numbers are +exact). The two range reads target lines 10–29 and 40–54 (the +mock's canned offsets/limits); the assertions use the very lines of +the string written to the fixture (never a literal), so the suite +stays true if the fixture is regenerated. + +The app under test is mock-calibrated (conftest pattern): +``BOR_RELEVANCE_THRESHOLD=0.30`` + ``BOR_LEXICAL_SUPPORT_FLOOR=0.15`` ++ ``BOR_SOURCE_USEFULNESS_FLOOR=0.15`` — the questions' best-chunk +cosine (the marker chunk wins) clears the floor with FTS hits (``twin`` +/ ``ranges`` / ``documented`` in the marker line), so every scripted +turn stays GROUNDED. + +Test → observable mapping (Playwright Mapping Rule; the story is the +owner's TODO item — one Playwright file per story, A16): +1. ``test_same_file_twice_shows_distinct_ranges`` — the marker + question (``read the same file twice: read samedoc/twice.md``) → + the SSE ``tool`` frames, IN ORDER, are the two range reads of the + SAME path with DISTINCT ranges (``offset`` 10/``limit`` 20, then + ``offset`` 40/``limit`` 15 — asserted on the FRAME's args); the + answer echoes BOTH ranges (each range's first line reached the + model's context — the read-echo); and the UI shows TWO Reading + lines for the SAME ```` path carrying the DISTINCT suffixes + `` (lines 10–29)`` and `` (lines 40–54)`` (the owner's confusion is + gone). +2. ``test_plain_read_has_no_range_span`` — a plain-read flow (the + mock's existing ``READ_CAP_TRIGGER`` — a ``read`` with no range) → + the SSE ``tool`` frame carries NO ``offset``/``limit`` keys + (byte-identical to pre-phase) and the Reading line has NO + ``.read-range`` span (the plain line's shape is untouched). +3. ``test_suffix_survives_save_restore`` — the marker question → the + live lines carry the suffixes → the conversation auto-saves (phase + 55) → ``/?chat=`` re-renders the saved record → the suffixes + come back (re-rendered from the saved record's additive + ``offset``/``limit`` — no migration). +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest +from playwright.sync_api import Locator, Page, expect +from sqlalchemy import select, text + +from app.config import Settings as _Settings +from app.db import SessionLocal +from app.models import Document +from e2e.auth_helpers import login +from e2e.conftest import ( + ADMIN_PASSWORD, + SESSION_SECRET, + USE_REAL_LLM, + _wait_http, +) +from e2e.mock_llm import ( + SAME_FILE_FIRST_LIMIT, + SAME_FILE_FIRST_OFFSET, + SAME_FILE_SECOND_LIMIT, + SAME_FILE_SECOND_OFFSET, +) + +REPO = Path(__file__).resolve().parents[2] + +# Phase 79 (task 04, full inventory): the conftest session app owns +# its port in a combined run — this module app binds its own port +# instead (a same-port second uvicorn dies on bind and would drive +# the wrong server). Env-overridable. +APP_PORT = int(os.environ.get("E2E_APP_PORT_SAMEFILE", "8149")) +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +SOURCE = "samedoc" # the local directory's basename = the source name +REL = "twice.md" +SP = f"{SOURCE}/{REL}" # the combined read identity + +#: The marker line (line 1) — the grounding words the questions share +#: (the mock-calibrated gate stays GROUNDED so both scripted flows +#: fire). It sits OUTSIDE both range slices (lines 10–29 / 40–54), so +#: it never pollutes the read-echo assertions. +MARKER_LINE = "SAMEFILE_MARKER the twin ranges of this document are documented here" + +TOTAL_LINES = 300 + + +def _doc_line(i: int) -> str: + """One fixed-length filler line (``Line : …``) — the line + numbers are exact and every line is the SAME length, so the range + slices' lengths are deterministic (20 lines > 15 lines).""" + prefix = f"Line {i:04d}: " + filler = "quiet mirror vault raid pool chain slot cycle guard probe audit " + return prefix + (filler * 5) [: 60 - len(prefix)] + + +def _doc() -> str: + """The 300-line fixture document (deterministic — module + docstring): the marker line, then 299 fixed-length filler lines.""" + lines = [MARKER_LINE] + lines.extend(_doc_line(i) for i in range(2, TOTAL_LINES + 1)) + return "\n".join(lines) + + +DOC = _doc() +DOC_LINES = DOC.splitlines() +DOC_TOTAL = len(DOC) +assert len(DOC_LINES) == TOTAL_LINES + +# The two range slices (1-based lines 10–29 and 40–54 — the mock's +# canned offsets/limits). Computed from the very string written to the +# fixture (the determinism pin — the assertions use these, never +# literals). +def _slice_lines(start: int, count: int) -> str: + """The 1-based lines ``start``..``start+count-1`` joined (the range + read's slice — the mock's canned offsets/limits).""" + return "\n".join(DOC_LINES[start - 1 : start - 1 + count]) + + +SLICE1 = _slice_lines(SAME_FILE_FIRST_OFFSET, SAME_FILE_FIRST_LIMIT) +SLICE2 = _slice_lines(SAME_FILE_SECOND_OFFSET, SAME_FILE_SECOND_LIMIT) +LEN1, LEN2 = len(SLICE1), len(SLICE2) +assert LEN2 < LEN1, (LEN2, LEN1) + +# THE SUBJECT: the read cap sits BETWEEN the two slices' lengths — the +# first (20-line) range slice truncates (the document enters the agent's +# truncated set, so the SECOND range read proceeds instead of being +# refused ``ALREADY_IN_CONTEXT``), the second (15-line) slice does not. +READ_CAP = (LEN1 + LEN2) // 2 +assert LEN2 <= READ_CAP < LEN1, (LEN2, READ_CAP, LEN1) +assert DOC_TOTAL > READ_CAP, "the plain read (test 2) must truncate too" + +# The read-echo assertions: each range's FIRST line reached the model's +# context (a single-line substring — robust to the answer's markdown +# rendering). Line 10 is the first line of range 1; line 40 the first +# line of range 2. +R1_FIRST_LINE = DOC_LINES[SAME_FILE_FIRST_OFFSET - 1] +R2_FIRST_LINE = DOC_LINES[SAME_FILE_SECOND_OFFSET - 1] +assert R1_FIRST_LINE.startswith("Line 0010") +assert R2_FIRST_LINE.startswith("Line 0040") + +# The expected suffixes (the readRangeSuffix contract — ASSUMPTION A1: +# the REQUESTED extent, offset–(offset+limit-1), en-dash per the pinned +# copy). +def _range_suffix(offset: int, limit: int) -> str: + """The readRangeSuffix contract for a well-formed range (ASSUMPTION + A1: the REQUESTED extent, offset–(offset+limit-1), en-dash per the + pinned copy).""" + return f" (lines {offset}–{offset + limit - 1})" + + +SUFFIX1 = _range_suffix(SAME_FILE_FIRST_OFFSET, SAME_FILE_FIRST_LIMIT) +SUFFIX2 = _range_suffix(SAME_FILE_SECOND_OFFSET, SAME_FILE_SECOND_LIMIT) +assert SUFFIX1 == " (lines 10–29)" +assert SUFFIX2 == " (lines 40–54)" + +# The scripted questions. Both carry the marker's grounding words (the +# mock-calibrated gate stays GROUNDED — the ```` section is +# present, so both flows fire). Q_A / Q_C carry ``SAME_FILE_TRIGGER`` +# + the target (the question-carried-call convention); Q_B carries the +# mock's existing ``READ_CAP_TRIGGER`` + the target (the plain read). +Q_A = f"Read the same file twice: read {SP} — what are the twin ranges documented here?" +Q_B = f"Read the capped document: read {SP} — what are the twin ranges documented here, plain read?" +Q_C = ( + f"Read the same file twice: read {SP} — " + "what are the twin ranges documented here, restore check?" +) + + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def samedoc_dirs(tmp_path_factory: pytest.TempPathFactory) -> Path: + """The story's local-directory source: one host temp dir (the app + server runs on the same host, so the path is visible to it) holding + the ONE generated 300-line document. The directory's basename is + the source name (``kind=local``, phase 38).""" + root = tmp_path_factory.mktemp("bor_same_file") / SOURCE + root.mkdir() + (root / REL).write_text(DOC, encoding="utf-8") + assert (root / REL).read_text(encoding="utf-8") == DOC + assert not (root / ".git").exists() # the local-kind story: NOT git + return root + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int, samedoc_dirs: Path) -> Iterator[str]: + """The real app under test — per-module env (the conftest pattern, + cf. ``test_read_line_ranges.py``): NO ``BOR_GIT_SOURCES`` (the env + fallback is git-only — the source here is a DB-registered local + directory), the mock LLM, the mock-calibrated gate (threshold 0.30 + + lexical floor 0.15 — the conftest pair) — and THE SUBJECT: a + SMALL ``BOR_READ_MAX_CHARS`` (between the two range slices' + lengths) so the first range read truncates and the second range + read proceeds (the ``ALREADY_IN_CONTEXT`` gate — see the module + docstring). The session app is never started in this isolated run, + so no port clash.""" + env = dict(os.environ) + env.pop("DEBUGPY", None) + env["BOR_ENVIRONMENT"] = "e2e" + env["BOR_STATIC_DIR"] = str(REPO / "frontend") + env["BOR_LLM_BASE_URL"] = ( + "https://aipi.reeseapps.com/v1" + if USE_REAL_LLM + else f"http://127.0.0.1:{mock_llm}/v1" + ) + # Mock-calibrated gate (conftest pattern): every scripted turn stays + # GROUNDED (the questions' best-chunk cosine clears the 0.15 floor + # with FTS hits — the marker chunk wins). + env["BOR_RELEVANCE_THRESHOLD"] = "0.30" + env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15" + # The read document must clear the citation bar into done.sources. + env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15" + # Phase 67: instant retry waits + the code-default budget (the + # conftest leak-guard pattern). + env["BOR_LLM_RETRY_DELAY"] = "0" + env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default) + env.setdefault( + "BOR_DATABASE_URL", + "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", + ) + # Phase 16: admin auth must be set or create_app() refuses to boot. + env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD + env["BOR_SESSION_SECRET"] = SESSION_SECRET + # The repo's .env file carries the owner's settings (the app reads + # it from cwd) — override the source list with an EMPTY value (the + # env var beats the .env file): the registry must hold EXACTLY the + # local directory this suite registers. + env["BOR_GIT_SOURCES"] = "" + # Leak guards (conftest pattern). + env["BOR_DOCS_REPO"] = "" + env["BOR_SUGGESTIONS"] = json.dumps( + _Settings.model_fields["suggestions"].default + ) + env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default + env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default + # THE SUBJECT: the small read cap (see the module docstring). + env["BOR_READ_MAX_CHARS"] = str(READ_CAP) + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], + cwd=REPO, + env=env, + ) + try: + _wait_http(f"{APP_URL}/api/health") + yield APP_URL + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def app_url(app_server: str) -> str: + return app_server + + +def _truncate_all() -> None: + """Fresh registry + KB (the E2E isolation pattern): the E2E suites + share one Postgres, so a leftover git_sources row or document would + pollute the retrieval the scripted turns run against (the + single-document KB's gate margins are pinned against EXACTLY this + one document).""" + with SessionLocal() as db: + db.execute( + text( + "TRUNCATE chunks, documents, query_log, steering_notes, " + "kb_overview, git_sources, folder_summaries" + ) + ) + db.commit() + + +def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]: + """Poll the (cookie-authenticated) status endpoint until the run + reaches a terminal state (the test_ls_tree_drilldown pattern, over + plain httpx — this fixture has no browser page yet).""" + deadline = time.monotonic() + timeout_s + body: dict[str, Any] = {} + while time.monotonic() < deadline: + r = client.get("/api/sync/status") + assert r.status_code == 200, r.text + body = r.json() + if body["state"] in ("success", "failed"): + return body + time.sleep(0.5) + raise AssertionError(f"sync did not reach a terminal state: {body}") + + +@pytest.fixture(scope="module") +def synced_kb(app_server: str, samedoc_dirs: Path) -> None: + """The story's precondition: the one-source KB synced under the + deterministic mock. Registers the temp directory through the + authenticated API (the ``test_local_directory_sources.py`` + pattern), runs the REAL in-process sync (``POST /api/sync`` — + walk → chunk → embed → overview → version bump), and pins the + stored content byte-identical to the fixture string.""" + _truncate_all() + with httpx.Client(base_url=app_server, timeout=30.0) as client: + r = client.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, r.text + r = client.post( + "/api/git-sources", json={"kind": "local", "path": str(samedoc_dirs)} + ) + assert r.status_code == 201, r.text + r = client.post("/api/sync") + assert r.status_code == 202, r.text + body = _wait_sync_done_http(client) + assert body["state"] == "success", body + detail = body["detail"] + assert detail["added"] == 1, detail + assert detail["pruned"] == 0, detail + # The import stored the fixture string BYTE-IDENTICALLY — the slice + # lengths every assertion uses are ``len()`` of the very string + # written to the directory. + with SessionLocal() as db: + stored = db.scalar( + select(Document.content).where( + Document.source == SOURCE, Document.path == REL + ) + ) + assert stored == DOC, "stored content drifted from the fixture" + + +@pytest.fixture(autouse=True) +def _clean(db_ready: None) -> Iterator[None]: + """Per-test query_log isolation (the KB itself is module-scoped — + the scripted turns never change it, so the registry persists + across the tests of this module).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE query_log")) + db.commit() + yield + with SessionLocal() as db: + db.execute(text("TRUNCATE query_log")) + db.commit() + + +# -------------------------------------------------------------------------- +# Page + API helpers (the test_read_line_ranges / test_chat_history +# house patterns) +# -------------------------------------------------------------------------- + +#: 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: + page.evaluate(SSE_HOOK) + + +def _frames(page: Page, deadline_s: float = 120.0) -> list[dict]: + """The SSE frames captured since the last submit (``_submit`` + clears the buffer), once the hook's background read settles.""" + deadline = time.monotonic() + deadline_s + 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, timeout_ms: int = 120_000) -> None: + """The turn is complete: answer text in the bubble, button + recovered (the phase-48 settle wait).""" + expect(page.locator(".msg.brain .bubble").last).not_to_have_text( + "", timeout=timeout_ms + ) + expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout_ms) + expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout_ms) + + +def _last_brain(page: Page) -> Locator: + return page.locator(".msg.brain").last + + +def _reading_lines(page: Page) -> Locator: + """The Reading lines for the fixture document (the ``.tool-call`` + lines whose ```` child carries the combined identity).""" + return page.locator(".tool-call", has=page.locator(f"code:has-text('{SP}')")) + + +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 _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]: + r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies) + assert r.status_code == 200 + return r.json()["chats"] + + +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 _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) + + +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: + last = _find_row(_chats(app_url, cookies), 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})") + + +# -------------------------------------------------------------------------- +# 1. The owner's "same file over and over": two range reads of the SAME +# file show DIFFERENT ranges (live, on the wire, and in the answer) +# -------------------------------------------------------------------------- + + +def test_same_file_twice_shows_distinct_ranges( + page: Page, app_url: str, synced_kb: None, db_ready: None +) -> None: + page.set_default_timeout(60_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, Q_A) + _wait_settled(page) + + frames = _frames(page) + # The owner's "same file over and over" on the wire — IN ORDER, + # asserted on the frames' ARGS (not a substring): the SAME path, + # TWO distinct ranges (offset 10/limit 20, then offset 40/limit 15 + # — the phase-131 additive fields ride the SAME frame). + assert _tool_frames(frames) == [ + { + "type": "tool", + "name": "read", + "argument": SP, + "offset": SAME_FILE_FIRST_OFFSET, + "limit": SAME_FILE_FIRST_LIMIT, + }, + { + "type": "tool", + "name": "read", + "argument": SP, + "offset": SAME_FILE_SECOND_OFFSET, + "limit": SAME_FILE_SECOND_LIMIT, + }, + ], _tool_frames(frames) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + # The read document is the turn's cited source (phase 119 A1). + assert any( + s["path"] == REL and s["source"] == SOURCE for s in done["sources"] + ), done["sources"] + + # The GROUNDED echo — BOTH ranges reached the model's context (the + # phase-131 read-echo shape: each range's first line lands in the + # answer — line 10 for range 1, line 40 for range 2). + text = _last_brain(page).locator(".bubble").text_content() or "" + assert R1_FIRST_LINE[:10] in text, f"range 1 not in the answer: {text!r}" + assert R2_FIRST_LINE[:10] in text, f"range 2 not in the answer: {text!r}" + # The echo names BOTH requested ranges (the en-dash, the pinned + # copy). + assert "lines 10–29" in text, text + assert "lines 40–54" in text, text + + # THE SUBJECT: TWO Reading lines for the SAME path carry the + # DISTINCT suffixes — the owner's confusion is gone. + lines = _reading_lines(page) + expect(lines).to_have_count(2) + codes = [lines.nth(i).locator("code").text_content() for i in range(2)] + assert codes == [SP, SP], f"both lines must read the SAME path: {codes}" + ranges = [ + lines.nth(i).locator(".read-range").text_content() for i in range(2) + ] + assert ranges == [SUFFIX1, SUFFIX2], f"the suffixes must differ: {ranges}" + assert ranges[0] != ranges[1], "two reads of one file must be distinguishable" + + +# -------------------------------------------------------------------------- +# 2. A plain read (no range) renders byte-identical to pre-phase: the +# tool frame carries no range keys and the line has no .read-range +# span +# -------------------------------------------------------------------------- + + +def test_plain_read_has_no_range_span( + page: Page, app_url: str, synced_kb: None, db_ready: None +) -> None: + page.set_default_timeout(60_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, Q_B) + _wait_settled(page) + + frames = _frames(page) + # The plain read on the wire: the tool frame carries NO + # offset/limit keys (a plain read — the phase-131 additive fields + # are ABSENT, never null — byte-identical to pre-phase). + assert _tool_frames(frames) == [ + {"type": "tool", "name": "read", "argument": SP} + ], _tool_frames(frames) + done = next(f for f in frames if f.get("type") == "done") + assert done["deflected"] is False, done + + # THE SUBJECT: the Reading line has NO .read-range span (the plain + # line's shape is untouched — byte-identical to pre-phase). The + # line still carries the pinned "📄 Reading " prefix + the + # path (and, for this large document, the orthogonal phase-95 + # truncation note — which is NOT a range span). + lines = _reading_lines(page) + expect(lines).to_have_count(1) + expect(lines.first.locator(".read-range")).to_have_count(0) + line_text = lines.first.text_content() or "" + assert line_text.startswith(f"📄 Reading {SP}"), line_text + # No range-suffix wording of any shape (lines / from line / first + # N lines). + assert " (lines " not in line_text, line_text + assert " (from line " not in line_text, line_text + assert " (first " not in line_text, line_text + + +# -------------------------------------------------------------------------- +# 3. The suffix survives save → restore: the saved record's additive +# offset/limit re-render the same suffixes on /?chat= +# -------------------------------------------------------------------------- + + +def test_suffix_survives_save_restore( + page: Page, app_url: str, synced_kb: None, db_ready: None +) -> None: + page.set_default_timeout(60_000) + login(page, app_url, next="/") + _install_page_hooks(page) + + _submit(page, Q_C) + _wait_settled(page) + + # The LIVE lines carry the suffixes (the save point persists the + # toolAcc records — {name, argument, offset, limit}). + lines = _reading_lines(page) + expect(lines).to_have_count(2) + live_ranges = [ + lines.nth(i).locator(".read-range").text_content() for i in range(2) + ] + assert live_ranges == [SUFFIX1, SUFFIX2], live_ranges + + # The conversation auto-saved (phase 55 — silent). Wait for the row + # by its auto-title (the first question). + cookies = _admin_cookies(page) + title = _auto_title(Q_C) + row = _wait_saved_row(app_url, cookies, title) + chat_id: str = row["id"] + try: + # Restore: /?chat= boots into the saved conversation through + # the SAME renderStoredMessage loop as the local restore — the + # saved record's additive offset/limit re-render the suffixes. + page.goto(app_url + f"/?chat={chat_id}") + restored = _reading_lines(page) + expect(restored).to_have_count(2, timeout=30_000) + codes = [restored.nth(i).locator("code").text_content() for i in range(2)] + assert codes == [SP, SP], f"restored lines must read the SAME path: {codes}" + ranges = [ + restored.nth(i).locator(".read-range").text_content() for i in range(2) + ] + assert ranges == [SUFFIX1, SUFFIX2], ( + f"the suffixes must re-render from the saved record: {ranges}" + ) + # The answer text restored too (the record is intact). + expect(_last_brain(page).locator(".bubble")).to_contain_text( + R1_FIRST_LINE[:10], timeout=15_000 + ) + finally: + _delete_chat(app_url, cookies, chat_id) diff --git a/tests/e2e/test_read_truncation_cap.py b/tests/e2e/test_read_truncation_cap.py index c6ba979..d8ba1ce 100644 --- a/tests/e2e/test_read_truncation_cap.py +++ b/tests/e2e/test_read_truncation_cap.py @@ -730,6 +730,9 @@ def test_truncated_read_save_and_shared_fidelity( detail = _get_chat(app_url, cookies, chat_id) brain_msgs = [m for m in detail["messages"] if m["who"] == "brain"] assert len(brain_msgs) == 1, detail["messages"] + # The full CURRENT shape (phase 139: + the additive range + # fields, null for a plain read — the mock's read is not a + # range read, so the record carries no range). assert brain_msgs[0]["tools"] == [ { "name": "read", @@ -737,6 +740,8 @@ def test_truncated_read_save_and_shared_fidelity( "truncated": True, "chars_shown": READ_CAP, "chars_total": CAP_DOC_TOTAL, + "offset": None, + "limit": None, } ], brain_msgs[0]["tools"] @@ -842,6 +847,11 @@ def test_short_read_control_no_frame_no_marker( "truncated": False, "chars_shown": None, "chars_total": None, + # Phase 139: the additive range fields, null for a + # plain read (the pre-phase 5-key shape still + # validates — stored as nulls, rendered suffix-less). + "offset": None, + "limit": None, } ], brain_msgs[0]["tools"] finally: diff --git a/tests/integration/test_chats_api.py b/tests/integration/test_chats_api.py index ed98dfe..557120f 100644 --- a/tests/integration/test_chats_api.py +++ b/tests/integration/test_chats_api.py @@ -59,6 +59,7 @@ SHARED_OUT_KEYS = {"title", "messages"} #: Phase 95 (task 02): the CURRENT full tool-entry shape — the additive #: truncation fields ride each record (a pre-phase-95 entry WITHOUT them #: still validates — the backward-compat pin in test_schemas.py). +#: Phase 139 (task 02): the additive range fields join the same entries. FULL_BRAIN: dict[str, Any] = { "who": "brain", "text": "Your k3s cluster runs on three nodes — you've got this.", @@ -78,6 +79,12 @@ FULL_BRAIN: dict[str, Any] = { "deflected": False, "suggestions": ["What ports does Traefik expose?"], "thinking": "The kubernetes doc covers the cluster layout…", + # Phase 139 (task 02): the read's requested range joins the same + # entries (additive — the truncation-fields precedent above): a + # save → restore round-trip keeps it, so the restore path and the + # shared page re-render the "(lines 42–71)" suffix. A pre-phase-139 + # entry WITHOUT the keys still validates (test_schemas.py's + # backward-compat pin) and stores nulls. "tools": [ { "name": "read", @@ -85,6 +92,8 @@ FULL_BRAIN: dict[str, Any] = { "truncated": True, "chars_shown": 128_000, "chars_total": 204_000, + "offset": 42, + "limit": 30, }, { "name": "ls", @@ -92,6 +101,8 @@ FULL_BRAIN: dict[str, Any] = { "truncated": False, "chars_shown": None, "chars_total": None, + "offset": None, + "limit": None, }, # Saved chats persisting the pre-phase-70 tool names still # validate — ``name`` is opaque to the API (no migration, @@ -102,6 +113,8 @@ FULL_BRAIN: dict[str, Any] = { "truncated": False, "chars_shown": None, "chars_total": None, + "offset": None, + "limit": None, }, ], "stopped": False, diff --git a/tests/unit/test_big_read_progress.py b/tests/unit/test_big_read_progress.py index cee977c..48dd6ef 100644 --- a/tests/unit/test_big_read_progress.py +++ b/tests/unit/test_big_read_progress.py @@ -197,7 +197,7 @@ 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)") + append = branch.find("appendToolLine(wrap, name, argument, frameOffset, frameLimit)") 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_message_failed.py b/tests/unit/test_chat_message_failed.py index fcdda58..ac180d2 100644 --- a/tests/unit/test_chat_message_failed.py +++ b/tests/unit/test_chat_message_failed.py @@ -142,10 +142,12 @@ def test_record_without_the_new_keys_validates_with_none() -> None: def test_pre_phase_record_round_trips_byte_identical_on_existing_keys() -> None: """The phase-50 contract through the new schema: a pre-phase-120 STORED record (every phase-50 key present, explicit nulls where an - optional key does not apply) validates, and re-serializes - byte-identically on EVERY pre-phase key — the only diff is the two - added keys as explicit nulls. Old saved chats and shared links - therefore render/restore exactly as before.""" + optional key does not apply — tool entries in the CURRENT full + shape, the phase-95/139 additive fields as nulls for a plain read) + validates, and re-serializes byte-identically on EVERY pre-phase + key — the only diff is the two added keys as explicit nulls. Old + saved chats and shared links therefore render/restore exactly as + before.""" pre_phase: dict = { "who": "brain", "text": "Your k3s cluster runs on three nodes — you've got this.", @@ -161,6 +163,8 @@ def test_pre_phase_record_round_trips_byte_identical_on_existing_keys() -> None: "truncated": False, "chars_shown": None, "chars_total": None, + "offset": None, + "limit": None, } ], "stopped": None, diff --git a/tests/unit/test_frontend_brand.py b/tests/unit/test_frontend_brand.py index 6a26892..b64f1ad 100644 --- a/tests/unit/test_frontend_brand.py +++ b/tests/unit/test_frontend_brand.py @@ -161,7 +161,10 @@ def test_app_js_labels_resolve_the_name_at_call_time() -> None: assert "`${brand()} is thinking`" in js assert "`${brand()} is answering`" in js assert "`${brand()} is still thinking (${secs}s)`" in js - assert "`${brand()} is reading ${argument}`" in js + assert ( + "`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`" + in js + ), ("phase 139: the read status carries the requested-range suffix (A2)") assert "`${brand()} is listing documents`" in js # The frozen module-level literals must be gone (stale-name bug). assert '"Brain of Reese is thinking"' not in js diff --git a/tests/unit/test_frontend_tool_states.py b/tests/unit/test_frontend_tool_states.py index 97a14a6..69430f6 100644 --- a/tests/unit/test_frontend_tool_states.py +++ b/tests/unit/test_frontend_tool_states.py @@ -50,17 +50,19 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None: "the turn handler must branch on tool frames" ) branch = js[tool_idx:delta_idx] - assert "toolAcc.push({ name, argument })" in branch, ( + assert "toolAcc.push(toolRecord)" in branch, ( "every tool frame is recorded for persistence — the record stays" " {name, argument}-generic, no per-tool shape (phase 68: the" - " search tool rides the same accumulator)" + " search tool rides the same accumulator; phase 139: the range" + " fields join additively — a non-range record keeps the exact" + " pre-phase shape)" ) assert "clearTurnTimeout()" in branch, "a tool frame proves the stream is alive" assert 'addMessage("brain", "")' in branch, "first frame creates the brain wrap" assert "uiState === UI_STATE.thinking" in branch, ( "label updates only while the state is still thinking" ) - assert "appendToolLine(wrap, name, argument)" in branch + assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" 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)" @@ -86,7 +88,10 @@ def test_calling_tool_label_strings() -> None: branch = js[tool_idx:delta_idx] assert "sendLabel" not in branch, "phase 48: the button keeps its Stop label" assert "`${brand()} is listing documents`" in branch - assert "`${brand()} is reading ${argument}`" in branch + assert ( + "`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`" + in branch + ), ("phase 139: the read status carries the requested-range suffix (A2)") # Phase 70: the read status — new + legacy name, locked # name+argument gate, first in the ternary. assert 'name === "read" || name === "read_document") && argument' in branch, ( @@ -222,7 +227,10 @@ 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)" 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" + ) thinking_restore = body.find("if (m.thinking)") tools_restore = body.find("Array.isArray(m.tools)") assert -1 < thinking_restore < tools_restore, ( diff --git a/tests/unit/test_read_range_tool_lines.py b/tests/unit/test_read_range_tool_lines.py new file mode 100644 index 0000000..6c09874 --- /dev/null +++ b/tests/unit/test_read_range_tool_lines.py @@ -0,0 +1,497 @@ +"""Unit: the phase-139 (task 03) "the Reading line shows which lines" +frontend contract. + +No new Python app logic exists for this task — the behavior lives in +``frontend/assets/app.js`` (the ``readRangeSuffix`` helper + the +``appendToolLine`` suffix span + the live ``tool`` branch's +``ev.offset``/``ev.limit`` read + the ``toolAcc`` persistence stamp + +the restore pass-through) and ``frontend/assets/shared.js`` (the shared +page's parity copy of the suffix + ``addToolLines``). Like the other +frontend-adjacent unit files (``test_frontend_tool_states.py`` is the +phase-37 precedent, ``test_read_truncation_frontend.py`` the phase-95 +one), this module pins the JS markers the story depends on so a silent +regression in the suffix wording, the well-formedness guard, the +persistence shape, or the shared-page parity is caught without a +browser. A small node-driven behavioral check (skipped when node is +unavailable, like the phase-10 renderer check in +``test_document_viewer.py``) proves the three suffix shapes, the +plain-read line's byte-identity to pre-phase, and the shared-page +parity. The E2E gate is the phase's story suite +(``tests/e2e/test_read_range_tool_lines.py``). +""" +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +APP_JS = FRONTEND / "assets" / "app.js" +SHARED_JS = FRONTEND / "assets" / "shared.js" + +HAVE_NODE = shutil.which("node") is not None + + +def _js() -> str: + return APP_JS.read_text(encoding="utf-8") + + +def _shared_js() -> str: + return SHARED_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 ``}`` (inner braces are indented, so the first + ``\\n}\\n`` is the function's own end — the + ``test_frontend_tool_states`` 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 _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 — readRangeSuffix: the single source of truth for the wording +# -------------------------------------------------------------------------- + + +def test_read_range_suffix_exists_with_the_pinned_literals() -> None: + """readRangeSuffix (app.js): the well-formedness guard mirrors the + server's ``_whole_line_number`` (``Number.isInteger(x) && x >= 1`` + — bools are not integers in JS), and the three suffix shapes carry + the EXACT pinned copy — both present → `` (lines {offset}–{offset + + limit - 1})`` (EN-DASH, ASSUMPTION A1: the REQUESTED extent), + offset only → `` (from line {offset})``, limit only → + `` (first {limit} lines)``, otherwise `` ""``.""" + body = _extract(_js(), "readRangeSuffix") + assert "Number.isInteger(offset) && offset >= 1" in body, ( + "the offset guard mirrors the server's _whole_line_number" + ) + assert "Number.isInteger(limit) && limit >= 1" in body, ( + "the limit guard mirrors the server's _whole_line_number" + ) + assert "` (lines ${offset}–${offset + limit - 1})`" in body, ( + "the both-present shape — the REQUESTED extent, en-dash per the" + " pinned copy (ASSUMPTION A1)" + ) + assert "` (from line ${offset})`" in body, "the offset-only shape" + assert "` (first ${limit} lines)`" in body, "the limit-only shape" + assert 'return ""' in body, "neither field well-formed → no suffix" + + +def test_read_range_suffix_shape_order() -> None: + """The both-present shape wins (it is checked first), then + offset-only, then limit-only, then the empty fallback — so a + well-formed pair always renders the range, never a one-sided + shape.""" + body = _extract(_js(), "readRangeSuffix") + both = body.find("if (offOk && limOk)") + off_only = body.find("if (offOk)") + lim_only = body.find("if (limOk)") + empty = body.find('return ""') + assert -1 < both < off_only < lim_only < empty, ( + "both → offset-only → limit-only → empty, in order" + ) + + +# -------------------------------------------------------------------------- +# app.js — appendToolLine: the suffix span on the Reading line +# -------------------------------------------------------------------------- + + +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.""" + 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)" + assert "readRangeSuffix(offset, limit)" in body, ( + "the read arm renders the suffix through the SAME helper" + ) + assert 'range.className = "read-range"' in body, ( + "the suffix rides a .read-range span" + ) + assert "range.textContent = rangeSuffix" in body, ( + "the range is data — textContent, never innerHTML" + ) + assert "if (rangeSuffix)" in body, ( + "the span is appended ONLY when the suffix is non-empty — a" + " plain read's line stays byte-identical" + ) + + +def test_append_tool_line_suffix_is_a_sibling_after_the_code() -> None: + """The .read-range span is a SIBLING appended AFTER the ```` + child (the phase-95 truncation-note precedent — append a span, + never rewrite the pinned template text), so the line's pinned + ``📄 Reading `` + ```` bytes are untouched.""" + body = _extract(_js(), "appendToolLine") + code_append = body.find("line.appendChild(code)") + range_append = body.find("line.appendChild(range)") + assert -1 < code_append < range_append, ( + "the range span lands AFTER the child" + ) + # The read arm's pinned template text is untouched (phase-87/117). + assert 'line.textContent = "📄 Reading "' in body + assert "innerHTML" not in body, ( + "no HTML injection surface on tool lines — textContent only" + ) + + +# -------------------------------------------------------------------------- +# app.js — the live `tool` branch: ev.offset/ev.limit + the toolAcc stamp +# -------------------------------------------------------------------------- + + +def test_live_tool_branch_reads_the_range_with_the_guard() -> None: + """The live ``tool`` branch reads ``ev.offset`` / ``ev.limit`` with + the SAME well-formedness guard (``Number.isInteger(x) && x >= 1`` + → ``null`` when absent / malformed), so a non-range frame keeps the + exact pre-phase ``{name, argument}`` record shape (the additive + ``bor.chat.v1`` JSONB — old records restore byte-identically, no + migration).""" + js = _js() + tool_idx = js.find('ev.type === "tool"') + delta_idx = js.find('ev.type === "delta"') + branch = js[tool_idx:delta_idx] + assert "Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null" in branch, ( + "ev.offset reads through the house guard → null when absent" + ) + assert "Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null" in branch, ( + "ev.limit reads through the house guard → null when absent" + ) + # The record carries the fields ONLY when non-null. + assert "const toolRecord = { name, argument }" in branch, ( + "the base record stays {name, argument}-generic" + ) + assert "if (frameOffset !== null) toolRecord.offset = frameOffset" in branch, ( + "offset joins the record ONLY when non-null" + ) + assert "if (frameLimit !== null) toolRecord.limit = frameLimit" in branch, ( + "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 + + +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.""" + js = _js() + tool_idx = js.find('ev.type === "tool"') + delta_idx = js.find('ev.type === "delta"') + branch = js[tool_idx:delta_idx] + assert ( + "`${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 + assert "`${brand()} is listing documents in ${argument}`" in branch + assert "`${brand()} is listing documents`" in branch + + +# -------------------------------------------------------------------------- +# app.js — the restore path: the stored range re-renders the suffix +# -------------------------------------------------------------------------- + + +def test_restore_path_passes_the_stored_range_through() -> None: + """The phase-14 restore path (renderStoredMessage): a stored tool + record with ``offset``/``limit`` (the live ``toolAcc`` stamp — + additive on ``bor.chat.v1``) re-renders the SAME suffix through the + SAME helper; a record saved before the phase (no fields) renders + byte-identically (the guard → null → no span).""" + js = _js() + fn = js.find("function renderStoredMessage") + assert fn != -1 + end = js.find("function restoreConversation") + body = js[fn:end] + assert "Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null" in body, ( + "the stored offset reads through the house guard" + ) + 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" + ) + + +# -------------------------------------------------------------------------- +# shared.js — the parity copy: identical literals + the addToolLines span +# -------------------------------------------------------------------------- + + +def test_shared_page_suffix_is_byte_identical_to_app_js() -> None: + """shared.js's ``readRangeSuffix`` is the parity copy — IDENTICAL + literals and IDENTICAL guard (the phase-50 restore contract: the + shared page re-renders saved records pixel-identically to the chat + page). The three suffix shapes + the guard must match app.js + EXACTLY.""" + shared = _extract(_shared_js(), "readRangeSuffix") + app = _extract(_js(), "readRangeSuffix") + for literal in ( + "Number.isInteger(offset) && offset >= 1", + "Number.isInteger(limit) && limit >= 1", + "` (lines ${offset}–${offset + limit - 1})`", + "` (from line ${offset})`", + "` (first ${limit} lines)`", + 'return ""', + ): + assert literal in shared, f"shared.js lost the parity literal {literal!r}" + assert literal in app, f"app.js lost the parity literal {literal!r}" + + +def test_shared_page_add_tool_lines_renders_the_range_span() -> None: + """shared.js's ``addToolLines``: the SAME .read-range span as the + chat page's ``appendToolLine`` — a sibling after the ```` + child, textContent only, appended ONLY when the suffix is non-empty + (a record without the fields renders exactly as today).""" + body = _extract(_shared_js(), "addToolLines") + assert "readRangeSuffix(t.offset, t.limit)" in body, ( + "the shared page renders the suffix from the stored record" + ) + assert 'range.className = "read-range"' in body + assert "range.textContent = rangeSuffix" in body + assert "if (rangeSuffix)" in body + code_append = body.find("line.appendChild(code)") + range_append = body.find("line.appendChild(range)") + assert -1 < code_append < range_append, ( + "the shared range span lands AFTER the child (parity)" + ) + assert "innerHTML" not in body, ( + "no HTML injection surface on shared tool lines — textContent only" + ) + + +def test_no_cdn_added() -> None: + """AGENTS.md rule 6: the range suffix adds no external + script/link.""" + index = (FRONTEND / "index.html").read_text(encoding="utf-8") + assert 'src="http' not in index and 'href="http' not in index + + +# -------------------------------------------------------------------------- +# behavior (node): the three suffix shapes, the plain line's +# byte-identity, and the shared-page parity +# -------------------------------------------------------------------------- + +#: A minimal DOM stub — just enough surface for appendToolLine / +#: addToolLines (createElement / querySelector / appendChild / +#: insertBefore / textContent / className / setAttribute). Plain strings +#: only; every node is snapshotted as a JSON row (the +#: test-document-viewer / test-chat-image-questions pattern). +_DOM_STUB = """ +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) }; +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) => ({ tag: c.tag, cls: c.className, text: c.textContent }) + ), + }; +} +""" + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_suffix_shapes_under_node() -> None: + """The REAL app.js ``readRangeSuffix`` under node — the three suffix + shapes + the malformed fallbacks (the guard: bools / strings / 0 are + not whole line numbers ≥ 1; a one-sided well-formed pair renders its + one-sided shape; a well-formed pair renders the range).""" + fn = _extract(_js(), "readRangeSuffix") + cases = [ + [42, 30, " (lines 42–71)"], + [42, None, " (from line 42)"], + [None, 5, " (first 5 lines)"], + [None, None, ""], + ["x", 5, " (first 5 lines)"], # malformed offset, valid limit + [42, "y", " (from line 42)"], # valid offset, malformed limit + [0, 5, " (first 5 lines)"], # offset 0 is not ≥ 1 + [42, 0, " (from line 42)"], # limit 0 is not ≥ 1 + [True, 5, " (first 5 lines)"], # a bool is not an integer + [1, 1, " (lines 1–1)"], # the smallest well-formed range + ] + driver = ( + fn + + "\nconst cases = " + + json.dumps([[c[0], c[1]] for c in cases]) + + ";\nconsole.log(JSON.stringify(cases.map((c) => readRangeSuffix(c[0], c[1]))));" + ) + out = json.loads(_run_node(driver)) + assert out == [c[2] for c in cases], out + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_plain_line_byte_identical_and_range_line_carries_the_suffix() -> None: + """The REAL app.js ``appendToolLine`` under node (against the DOM + stub): a PLAIN read (no offset/limit) renders the pre-phase line + byte-identical — ``📄 Reading path``, a single ``code`` + child, NO ``.read-range`` span — while a RANGE read appends the + ``.read-range`` sibling after the ``code`` with the exact suffix.""" + suffix = _extract(_js(), "readRangeSuffix") + append = _extract(_js(), "appendToolLine") + driver = ( + _DOM_STUB + + "\n" + + suffix + + "\n" + + append + + '\nconst ARG = "samedoc/twice.md";\n' + + "const w1 = makeWrap();\n" + + 'appendToolLine(w1, "read", ARG);\n' + + 'const plain = describe(w1.querySelector(".tool-call"));\n' + + "const w2 = makeWrap();\n" + + 'appendToolLine(w2, "read", ARG, 10, 20);\n' + + 'const range = describe(w2.querySelector(".tool-call"));\n' + + "console.log(JSON.stringify({ plain, range }));" + ) + out = json.loads(_run_node(driver)) + plain, range = out["plain"], out["range"] + # The plain line: byte-identical to pre-phase — ONE child, no + # .read-range span, the exact pinned text. + assert plain["text"] == "📄 Reading samedoc/twice.md", plain + assert len(plain["children"]) == 1, plain + assert plain["children"][0] == {"tag": "code", "cls": "", "text": "samedoc/twice.md"}, plain + assert all(c["cls"] != "read-range" for c in plain["children"]), ( + "a plain read must carry NO .read-range span" + ) + # The range line: the child PLUS the .read-range sibling with + # the exact suffix (10 + 20 - 1 = 29, en-dash). + assert range["text"] == "📄 Reading samedoc/twice.md (lines 10–29)", range + assert range["children"][0] == {"tag": "code", "cls": "", "text": "samedoc/twice.md"}, range + assert range["children"][1] == { + "tag": "span", + "cls": "read-range", + "text": " (lines 10–29)", + }, range + + +@pytest.mark.skipif(not HAVE_NODE, reason="node not available") +def test_shared_page_line_parity_under_node() -> None: + """The REAL shared.js ``addToolLines`` under node renders the SAME + line as app.js's ``appendToolLine`` for the same record (the + parity contract — the shared page re-renders saved records + pixel-identically to the chat page): identical text and identical + children (the ``code`` + the ``.read-range`` span with the same + suffix). A record WITHOUT the fields renders the plain line on both + pages.""" + shared_suffix = _extract(_shared_js(), "readRangeSuffix") + add_lines = _extract(_shared_js(), "addToolLines") + app_suffix = _extract(_js(), "readRangeSuffix") + append = _extract(_js(), "appendToolLine") + driver = ( + _DOM_STUB + + "\n" + + app_suffix + + "\n" + + append + + '\nconst ARG = "samedoc/twice.md";\n' + # app.js: a range read + a plain read + + "const wa = makeWrap();\n" + + 'appendToolLine(wa, "read", ARG, 10, 20);\n' + + 'const appRange = describe(wa.querySelector(".tool-call"));\n' + + "const wp = makeWrap();\n" + + 'appendToolLine(wp, "read", ARG);\n' + + 'const appPlain = describe(wp.querySelector(".tool-call"));\n' + # shared.js: the SAME record (range + no-fields) through addToolLines + + shared_suffix.replace("function readRangeSuffix", "function sharedSuffix") + + "\n" + + add_lines.replace( + "readRangeSuffix(t.offset, t.limit)", "sharedSuffix(t.offset, t.limit)" + ) + + "const ws = makeWrap();\n" + + 'addToolLines(ws, [{ name: "read", argument: ARG, offset: 10, limit: 20 }]);\n' + + 'const sharedRange = describe(ws.querySelector(".tool-call"));\n' + + "const wn = makeWrap();\n" + + 'addToolLines(wn, [{ name: "read", argument: ARG }]);\n' + + 'const sharedPlain = describe(wn.querySelector(".tool-call"));\n' + + "console.log(JSON.stringify({ appRange, appPlain, sharedRange, sharedPlain }));" + ) + out = json.loads(_run_node(driver)) + # The range line is pixel-identical across the two pages. + assert out["sharedRange"] == out["appRange"], ( + f"parity broken (range): {out['sharedRange']} vs {out['appRange']}" + ) + # A record without the fields renders the plain line on both pages. + assert out["sharedPlain"] == out["appPlain"], ( + f"parity broken (plain): {out['sharedPlain']} vs {out['appPlain']}" + ) + # And the plain line carries no .read-range span (byte-identical). + assert all(c["cls"] != "read-range" for c in out["sharedPlain"]["children"]) diff --git a/tests/unit/test_read_truncation_frontend.py b/tests/unit/test_read_truncation_frontend.py index db20353..f024f1b 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)" in body + assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" 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)") + line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)") note_idx = body.find("appendTruncatedNote(wrap, arg,") assert -1 < line_idx < note_idx diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index ef0b170..2c15d30 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -45,12 +45,16 @@ def _source_ref() -> dict: def _tool_call() -> dict: # Phase 95 (task 02): the truncation record rides the same entry — # the CURRENT full shape (additive fields, defaults for a plain read). + # Phase 139 (task 02): the read's requested range joins the same + # entry (additive — None for a plain read, the phase-95 pattern). return { "name": "read", "argument": "Homelab/kubernetes.md", "truncated": False, "chars_shown": None, "chars_total": None, + "offset": None, + "limit": None, } @@ -401,12 +405,18 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None: "tools": [ # Phase 95 (task 02): the current full shape — one # truncated read (the marker record) + a plain ls. + # Phase 139 (task 02): the truncated read is a RANGE + # read — the requested offset/limit ride the same entry + # so a save → restore re-renders the "(lines 42–71)" + # suffix; the ls carries no range (None, as serialized). { "name": "read", "argument": "Homelab/kubernetes.md", "truncated": True, "chars_shown": 128_000, "chars_total": 204_000, + "offset": 42, + "limit": 30, }, { "name": "ls", @@ -414,6 +424,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None: "truncated": False, "chars_shown": None, "chars_total": None, + "offset": None, + "limit": None, }, ], "stopped": None, @@ -484,24 +496,78 @@ def test_realistic_payload_round_trips_through_update_model() -> None: def test_tool_call_round_trips_with_truncation_fields() -> None: - """The CURRENT full shape (phase 95 task 02): a truncated read's - record (``truncated: True`` + the two non-negative counts) validates + """The CURRENT full shape (phase 95 task 02 + phase 139 task 02): + a truncated read's record (``truncated: True`` + the two non-negative + counts + the additive range fields, absent for a plain read) validates and round-trips ``model_dump()`` unchanged — the save payload carries - it with zero other change (the UI re-renders the marker from it).""" + it with zero other change (the UI re-renders the marker — and, for a + range read, the "(lines N–M)" suffix — from it).""" raw = { "name": "read", "argument": "Homelab/big.md", "truncated": True, "chars_shown": 128_000, "chars_total": 204_000, + "offset": None, + "limit": None, } call = ToolCall.model_validate(raw) assert call.truncated is True assert call.chars_shown == 128_000 assert call.chars_total == 204_000 + assert call.offset is None and call.limit is None assert call.model_dump() == raw +def test_tool_call_range_fields_round_trip() -> None: + """Phase 139 (task 02): a RANGE read's record (``offset`` + + ``limit``, the phase-131 wire fields) validates and round-trips + ``model_dump()`` unchanged — the saved chat's ``tools`` JSONB keeps + the range, so the chat page's restore path and the shared page + re-render the "(lines N–M)" suffix from the stored row. Only ONE of + the two is also a real shape (the model may pass a start without a + count — the UI's "(from line N)" line).""" + both = ToolCall.model_validate( + {"name": "read", "argument": "Homelab/big.md", "offset": 42, "limit": 30} + ) + assert (both.offset, both.limit) == (42, 30) + assert both.model_dump() == { + "name": "read", + "argument": "Homelab/big.md", + "truncated": False, + "chars_shown": None, + "chars_total": None, + "offset": 42, + "limit": 30, + } + offset_only = ToolCall.model_validate( + {"name": "read", "argument": "Homelab/big.md", "offset": 7} + ) + assert (offset_only.offset, offset_only.limit) == (7, None) + limit_only = ToolCall.model_validate( + {"name": "read", "argument": "Homelab/big.md", "limit": 20} + ) + assert (limit_only.offset, limit_only.limit) == (None, 20) + + +def test_tool_call_range_fields_reject_zero_or_negative() -> None: + """Phase 139 (task 02) bounds (the phase-83 philosophy, the UI's + guard mirrored): a line number is >= 1 (the server's + ``_whole_line_number`` bound — 0 / negative are not real ranges, and + the UI never legitimately sends them; the bool exclusion lives on + the SSE-side guard, where frames are minted).""" + for bad_offset, bad_limit in ((0, None), (None, 0), (-3, None), (None, -1)): + with pytest.raises(ValidationError): + ToolCall.model_validate( + { + "name": "read", + "argument": "x", + "offset": bad_offset, + "limit": bad_limit, + } + ) + + def test_tool_call_old_shape_validates_with_defaults() -> None: """Backward-compat (the phase-50 rule): a saved chat written BEFORE phase 95 — tool records without the truncation fields — validates diff --git a/tests/unit/test_tool_call_compact.py b/tests/unit/test_tool_call_compact.py index 737df5c..2defd39 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)" in body + assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body assert "closeToolCalls(wrap)" in body assert ( - body.find("appendToolLine(wrap, t.name, arg)") + body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)") < body.find("closeToolCalls(wrap)") ), "the fold sits AFTER the tool-line restore loop"