phase: 139_read_range_tool_lines
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)` — `<span class="read-range">` sibling after `<code>`, 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`
This commit is contained in:
@@ -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)` — `<span class="read-range">` sibling after `<code>`, 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`
|
||||
+117
@@ -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
|
||||
+17
@@ -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 `<span class="read-range">` (textContent, span SIBLING after `<code>` — 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)
|
||||
+117
@@ -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
|
||||
+18
@@ -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).
|
||||
+117
@@ -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
|
||||
+18
@@ -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 `<code>` path, distinct suffixes `(lines 10–29)`/`(lines 40–54)`; (b) plain read → no `.read-range` span; (c) auto-save → `/?chat=<id>` → 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).
|
||||
+117
@@ -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
|
||||
@@ -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 —
|
||||
|
||||
+80
-7
@@ -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 <details> 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 <code> 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 `<code>` 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
|
||||
// <code> 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
|
||||
|
||||
@@ -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 <code> 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
|
||||
|
||||
@@ -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 ``<tools>`` 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: <q1>; lines 40–54: <q2>`` where each ``<qN>`` 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 ``<tools>`` 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 ``<tools>`` 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<arg>[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: <q1>; lines 40–54:
|
||||
<q2>`` where each ``<qN>`` 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 ``<tools>``
|
||||
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 "<tools>" 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 ``<tools>`` 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 ``<documents>`` block with the image-
|
||||
|
||||
@@ -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 <code>source/path</code> (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=<id>`` 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
|
||||
``<tools>`` section is present, so both scripted flows fire); lines
|
||||
2–300 are fixed-length filler (``Line <i>: …`` — 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 ``<code>`` 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=<id>`` 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 <i>: …``) — 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 ``<tools>`` 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 ``<code>`` 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 <code> 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 <code>
|
||||
# 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=<id>
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
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=<id> 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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 ``<code>``
|
||||
child (the phase-95 truncation-note precedent — append a span,
|
||||
never rewrite the pinned template text), so the line's pinned
|
||||
``📄 Reading `` + ``<code>`` 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 <code> 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 ``<code>``
|
||||
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 <code> 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 <code>path</code>``, 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 <code> 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 <code> 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"])
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user