phase: 95_read_truncation_cap
Build and Push Containers / build-and-push-app (push) Successful in 1m38s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All gates are green. Final verification is complete — no fixes were needed; the phase is fully implemented and passing.

**Phase 95 final verification report**

- Verified all 3 tasks already implemented (task files in `complete/`): read cap + notice + `ToolResultPiece` (task 01), `tool_result` SSE + live/saved/shared marker (task 02), E2E suite (task 03).
- Code checks passed: `BOR_READ_MAX_CHARS` (default 128 000, validator), pinned `READ_TRUNCATION_NOTICE` + `TRUNCATION_MARKER`, byte-identical at/under-cap read, holder `read_truncations` (no `tool_calls` impact), piece order (after `tool`, before next round), `ChatToolResultEvent`, `ToolCall` back-compat fields, `app.js` live/restore handlers, `shared.js`, `.truncated-note` CSS, `.env.example` entry, mock-LLM echo script.
- **Tests:** `uv run pytest` → 1966 passed; `uv run pytest --cov=app --cov-report=term-missing` → all green, TOTAL **99%** (>90% gate).
- **E2E:** `uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov` → **3 passed** (frame order + live marker + LLM notice via echo; save→shared fidelity; short-read control).
- **Regression (isolated):** `test_agent_document_tools` 4 ✓, `test_chat_history` 5 ✓, `test_share_chat` 4 ✓, `test_big_read_progress` 4 ✓, `test_stop_generation` 3 ✓.
- **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.

**Completion criteria:** ① over-cap read → first-cap-chars + marker + pinned notice — ✓ (unit-pinned: at-cap/cap+1/notice tests); ② user marker live/saved/shared — ✓ (E2E + frontend tests); ③ at/under cap byte-identical, no frame — ✓ (unit + control E2E); ④ top-2 `<documents>` retrieval untouched — ✓ (`app/rag/retriever.py` unmodified vs HEAD); ⑤ suite green, >90% coverage, ruff+pyright clean — ✓; ⑥ no completed-phase behavior change — ✓ (all gates green; commit left to harness per pass rules).

- No defects found; no changes made this pass. Next pending phase: none in `todo/` (96 is the next free number).
This commit is contained in:
2026-09-11 03:42:51 -04:00
parent d4943b4822
commit bcaef800c5
36 changed files with 2836 additions and 43 deletions
@@ -0,0 +1,14 @@
All gates are green. Final verification is complete — no fixes were needed; the phase is fully implemented and passing.
**Phase 95 final verification report**
- Verified all 3 tasks already implemented (task files in `complete/`): read cap + notice + `ToolResultPiece` (task 01), `tool_result` SSE + live/saved/shared marker (task 02), E2E suite (task 03).
- Code checks passed: `BOR_READ_MAX_CHARS` (default 128 000, validator), pinned `READ_TRUNCATION_NOTICE` + `TRUNCATION_MARKER`, byte-identical at/under-cap read, holder `read_truncations` (no `tool_calls` impact), piece order (after `tool`, before next round), `ChatToolResultEvent`, `ToolCall` back-compat fields, `app.js` live/restore handlers, `shared.js`, `.truncated-note` CSS, `.env.example` entry, mock-LLM echo script.
- **Tests:** `uv run pytest` → 1966 passed; `uv run pytest --cov=app --cov-report=term-missing` → all green, TOTAL **99%** (>90% gate).
- **E2E:** `uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov` → **3 passed** (frame order + live marker + LLM notice via echo; save→shared fidelity; short-read control).
- **Regression (isolated):** `test_agent_document_tools` 4 ✓, `test_chat_history` 5 ✓, `test_share_chat` 4 ✓, `test_big_read_progress` 4 ✓, `test_stop_generation` 3 ✓.
- **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
**Completion criteria:** ① over-cap read → first-cap-chars + marker + pinned notice — ✓ (unit-pinned: at-cap/cap+1/notice tests); ② user marker live/saved/shared — ✓ (E2E + frontend tests); ③ at/under cap byte-identical, no frame — ✓ (unit + control E2E); ④ top-2 `<documents>` retrieval untouched — ✓ (`app/rag/retriever.py` unmodified vs HEAD); ⑤ suite green, >90% coverage, ruff+pyright clean — ✓; ⑥ no completed-phase behavior change — ✓ (all gates green; commit left to harness per pass rules).
- No defects found; no changes made this pass. Next pending phase: none in `todo/` (96 is the next free number).
@@ -0,0 +1,95 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 98%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 110 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 124 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 110 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 224 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 279 0 100%
--------------------------------------------------
TOTAL 3684 13 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
@@ -0,0 +1,20 @@
Task 01 complete. All gates green.
**Implemented**
- `BOR_READ_MAX_CHARS` (default 128 000 ≈ 32k tokens, spec in docstring) + non-negative validator (`app/config.py`)
- `READ_TRUNCATION_NOTICE` + capped `read` branch (marker + notice, byte-identical at/under cap) + `AgentHolder.read_truncations` (`app/rag/agent.py`)
- `ToolResultPiece` (frozen, piece family) + `run_agent` yields it after the tool frame / before next round (`app/rag/llm.py`, `agent.py`)
- Pinned `read` tool description + one `TOOLS_SECTION` line + module-docstring A7-scope note
- 11 new tests (unit boundary/pinned/holder/order/refusal + config + prompt + 2 real-prompt-path integration)
**Results**
- `uv run pytest --cov=app` → **1950 passed**, app/ coverage **99%** (≥90% gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
- E2E `test_agent_document_tools` / `test_big_read_progress` / `test_chat_history` / `test_share_chat` → all pass
**Decisions/deviations**
- Added a `>=0` validator (house pattern) — a negative cap would silently slice from the end.
- Minimal `app/api/chat.py` seam to stay pyright-green & crash-free: widened stream types + a `_pump` branch that **consumes** the piece (no frame). The SSE `tool_result` frame + UI marker are task 02's explicit scope.
- `ToolResultPiece.argument` = raw `source/path` (matches the `tool` frame for task-02 UI matching).
**Next pending task:** `02_sse_and_ui.md`
@@ -0,0 +1,95 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 22%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 33%]
........................................................................ [ 36%]
........................................................................ [ 40%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 51%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 62%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 73%]
........................................................................ [ 77%]
........................................................................ [ 81%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
........................................................................ [ 99%]
...... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 180 1 99%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 110 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 124 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 110 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 224 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 269 0 100%
--------------------------------------------------
TOTAL 3673 14 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
@@ -0,0 +1,14 @@
All verification complete. Final report:
**Task 02 — `tool_result` SSE event + visible truncation marker (live, saved, shared)**
- `app/schemas.py`: new `ChatToolResultEvent` (A15 7th optional event, docstring cites owner permission); `ToolCall` gains `truncated: bool = False` + `chars_shown/chars_total: int | None (ge=0)` — pre-phase-95 JSON validates unchanged (phase-50 rule)
- `app/api/chat.py`: `_pump` now emits the `tool_result` frame from `ToolResultPiece` (replacing task 01's consume-stub) — after the matching `tool` frame, before the next round; module docstring documents the A15 extension
- `frontend/assets/app.js`: `tool_result` handler appends ` (truncated — showing N of M chars)` to the newest matching Reading line (`appendTruncatedNote`, createElement/textContent only) + stamps the newest `toolAcc` entry (save payload carries it); phase-14 restore re-renders the marker
- `frontend/assets/shared.js`: identical marker from the stored record; `styles.css`: `.tool-call .truncated-note { color: var(--ink-soft); }` (theme-neutral, no new hue)
- Tests: SSE integration (frame order/shape/counts; non-truncated read streams none), schema unit (round-trip + backward-compat + event shape), new `tests/unit/test_read_truncation_frontend.py` (8 pins); updated two round-trip pins to the new additive `ToolCall` shape
- Results: `uv run pytest --cov=app` → **1966 passed, app/ 99%**; `uv run ruff check .` clean; `uv run pyright` → 0 errors; E2E sanity in isolation: `test_agent_document_tools` (4), `test_share_chat` (4), `test_chat_history` (5), `test_big_read_progress` (4) all passed
Deviations: none (CSS token is `var(--ink-soft)` — the task's `--ink_soft` is a typo of the house token). No git commit made (harness commits).
**Next pending task:** `.agents/phases/todo/95_read_truncation_cap/03_e2e_truncation_cap.md` (the dedicated story E2E suite).
@@ -0,0 +1,95 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 98%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 110 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 124 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 110 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 224 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 279 0 100%
--------------------------------------------------
TOTAL 3684 13 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
@@ -0,0 +1,17 @@
Task 03 complete — all gates green.
**Implemented**
- `tests/e2e/test_read_truncation_cap.py` (new, isolated, mock-only): module app boots with `BOR_READ_MAX_CHARS=1500` on its own port (8137); one local-dir source (`capkb`) with 3 token-controlled docs (~3 122-char subject, ~200-char control, a digit-named retrieval anchor that keeps each read target out of its own question's top-2 seeds, so the ALREADY_IN_CONTEXT dedupe never fires — pinned at setup via the app's real hybrid retrieval)
- `tests/e2e/mock_llm.py` (+129): new `READ_CAP_TRIGGER` scripted flow — the question carries `read the capped document: read <source>/<file>.md`; the mock emits the `read` call, then echoes the entire tool result verbatim (the house lens on the LLM's context)
**Assertions encoded**: `tool` → exactly one `tool_result` (`truncated: true, chars_shown: 1500, chars_total: len(CAP_DOC)`) → `delta`/`done`; live Reading-line marker ` (truncated — showing 1500 of N chars)`; echo carries `[…truncated…]` + the pinned `TRUNCATED — … Use grep …` notice (head sentinel in, past-cap tail sentinel out); saved row's `tools` record via the chats API → shared page renders the same marker; control turn: no `tool_result`, no marker, no marker text, plain `tools` record.
**Results**
- `uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov` → 3 passed (12.8s)
- `uv run pytest --cov=app --cov-report=term-missing` → 1966 passed, coverage 99% (>90%)
- Regression gates in isolation: `test_agent_document_tools` 4 passed, `test_chat_history` 5 passed, `test_share_chat` 4 passed, `test_big_read_progress` 4 passed, `test_stop_generation` 3 passed
- `uv run ruff check .` + `uv run pyright` → clean
**Decisions**: dedicated module app (per-module env pattern) since the cap is read at app boot; retrieval-anchor fixture design validated numerically against a scratch DB before writing the suite (2× score margins). No deviations.
**Next pending task**: none in `95_read_truncation_cap/` — phase 95 is complete (harness commits + moves).
@@ -0,0 +1,95 @@
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 32%]
........................................................................ [ 36%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
........................................................................ [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
........................................................................ [ 98%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 181 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 94 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 229 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 29 0 100%
app/api/sync.py 110 0 100%
app/api/tokens.py 28 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 140 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 33 0 100%
app/db.py 21 0 100%
app/main.py 66 0 100%
app/models.py 124 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 316 1 99%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/folder_summaries.py 110 0 100%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 190 3 98%
app/rag/llm.py 224 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 88 0 100%
app/rag/retriever.py 150 3 98%
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 24 0 100%
app/schemas.py 279 0 100%
--------------------------------------------------
TOTAL 3684 13 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
+1
View File
@@ -48,6 +48,7 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
# --- Agent document tools (grounded turns may extend context: ls / read / grep) --- # --- Agent document tools (grounded turns may extend context: ls / read / grep) ---
# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools) # BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools)
# BOR_READ_MAX_CHARS=128000 # cap on a `read` result (chars); over-cap reads truncate + a grep-pointer notice (phase 95)
# --- Import scope (A9 default; ANY well-formed extension is allowed) --- # --- Import scope (A9 default; ANY well-formed extension is allowed) ---
# Comma-separated file extensions (lowercase, no dot) the importer reads. # Comma-separated file extensions (lowercase, no dot) the importer reads.
+51 -7
View File
@@ -63,7 +63,23 @@ of the answer's ``delta`` frames: ``argument`` is the single string the
model passed — ``read``'s ``path`` (the combined ``source/path``), model passed — ``read``'s ``path`` (the combined ``source/path``),
``grep``'s ``pattern``, ``ls``'s ``path`` — or null (a non-string ``grep``'s ``pattern``, ``ls``'s ``path`` — or null (a non-string
value — a model error the backend refuses — and an omitted argument value — a model error the backend refuses — and an omitted argument
both yield null). ``done.sources``, ``query_log.sources`` and the both yield null).
Tool-result frames (phase 95, ``TODO.md`` L5 — A15 extension, owner
permission 2026-09-10; the event-type list grows from six to SEVEN,
``tool_result`` among them; PLAN.md is being redone by the owner): a
``read`` whose document is longer than ``BOR_READ_MAX_CHARS`` streams,
AFTER the matching ``tool`` frame (the line is already on screen — the
marker lands a beat later, the phase-37/48 tool-line timing is
untouched) and BEFORE the next model round, exactly one optional
``tool_result`` event — ``{"type": "tool_result", "name": "read",
"argument": …, "truncated": true, "chars_shown": N, "chars_total": M}``
— the additive truncation notice the UI turns into the
"(truncated — showing N of M chars)" marker on the Reading line. A
non-truncated read streams NO such frame (one frame = one noteworthy
event), deflected turns never stream one (the agent never runs, A8),
and every pre-existing frame is byte-identical — clients that do not
know the type ignore it. ``done.sources``, ``query_log.sources`` and the
per-turn log line all report the same combined source list (retrieval per-turn log line all report the same combined source list (retrieval
docs + the agent's read docs, deduped by ``(source, path)``, order docs + the agent's read docs, deduped by ``(source, path)``, order
preserved — a grep adds no source; it is a locator, locked A5), and the preserved — a grep adds no source; it is a locator, locked A5), and the
@@ -165,6 +181,7 @@ from app.rag.llm import (
RetryPiece, # phase 67: one LLM request restart (an SSE retry frame) RetryPiece, # phase 67: one LLM request restart (an SSE retry frame)
StreamPiece, # type of the answer pieces streamed by the agent loop StreamPiece, # type of the answer pieces streamed by the agent loop
ToolCallPiece, # phase 37: one model-requested tool call ToolCallPiece, # phase 37: one model-requested tool call
ToolResultPiece, # phase 95: one truncated tool result (SSE frame = task 02)
chat_stream_retried, # phase 67: the retry-before-first-piece primitive chat_stream_retried, # phase 67: the retry-before-first-piece primitive
) )
from app.rag.overview import load_kb_overview from app.rag.overview import load_kb_overview
@@ -179,6 +196,7 @@ from app.schemas import (
ChatRetryEvent, ChatRetryEvent,
ChatThinkingEvent, ChatThinkingEvent,
ChatToolEvent, ChatToolEvent,
ChatToolResultEvent,
SourceRef, SourceRef,
) )
@@ -435,7 +453,9 @@ async def chat(
# ``agent_max_rounds=0`` ``run_agent`` is a single # ``agent_max_rounds=0`` ``run_agent`` is a single
# ``tools=None`` request anyway (the kill switch). # ``tools=None`` request anyway (the kill switch).
holder = AgentHolder() holder = AgentHolder()
answer_stream: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece] answer_stream: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
]
deflected_filter: ScaffoldingFilter | None = None deflected_filter: ScaffoldingFilter | None = None
if plan.deflected: if plan.deflected:
# Phase 67: the deflected stream goes through the retry # Phase 67: the deflected stream goes through the retry
@@ -474,13 +494,18 @@ async def chat(
scaffold_stripped = 0 # phase 71: sum across the turn's requests scaffold_stripped = 0 # phase 71: sum across the turn's requests
async def _pump( async def _pump(
pieces: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece], pieces: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
],
) -> AsyncIterator[str]: ) -> AsyncIterator[str]:
"""One request's piece loop (phase 71 extraction): the """One request's piece loop (phase 71 extraction): the
thinking/tool/retry/delta handling shared by the turn's thinking/tool/retry/tool_result/delta handling shared by
first pass and — deflected path only — the one bounded the turn's first pass and — deflected path only — the one
recovery. Behavior-preserving for the first pass (pinned bounded recovery. Behavior-preserving for the first pass
by the existing integration suite).""" (pinned by the existing integration suite). Phase 95:
the ``ToolResultPiece`` branch emits the additive
``tool_result`` SSE frame (the seventh, optional event
type — the A15 extension)."""
nonlocal thinking_chars, content_chars, retries_used nonlocal thinking_chars, content_chars, retries_used
async for piece in pieces: # StreamPiece | ToolCallPiece | RetryPiece async for piece in pieces: # StreamPiece | ToolCallPiece | RetryPiece
if isinstance(piece, ToolCallPiece): if isinstance(piece, ToolCallPiece):
@@ -513,6 +538,25 @@ async def chat(
).model_dump() ).model_dump()
) )
continue continue
if isinstance(piece, ToolResultPiece):
# Phase 95 (A15 extension, task 02): one optional
# ``tool_result`` frame per truncated ``read`` —
# emitted HERE, right where the agent loop yielded
# the piece: AFTER the matching ``tool`` frame and
# BEFORE the next model round. Additive: a
# non-truncated read yields no piece at all (no
# frame), and the other six event types are
# byte-identical.
yield sse_event(
ChatToolResultEvent(
name=piece.name,
argument=piece.argument,
truncated=piece.truncated,
chars_shown=piece.chars_shown,
chars_total=piece.chars_total,
).model_dump()
)
continue
if piece.kind == "thinking": if piece.kind == "thinking":
thinking_chars += len(piece.text) thinking_chars += len(piece.text)
if settings.stream_thinking: if settings.stream_thinking:
+28
View File
@@ -152,6 +152,23 @@ class Settings(BaseSettings):
#: request with ``tools=None`` (the pre-phase-37 path — the kill #: request with ``tools=None`` (the pre-phase-37 path — the kill
#: switch). Negative values are rejected at startup (validator). #: switch). Negative values are rejected at startup (validator).
agent_max_rounds: int = 10 agent_max_rounds: int = 10
#: Cap in characters on the agent ``read`` tool's result (phase 95,
#: ``BOR_READ_MAX_CHARS``): a document LONGER than this is cut at the
#: cap and the shared ``[…truncated…]`` marker plus the grep-pointer
#: notice (``app.rag.agent``) are appended; a document at or under the
#: cap is read whole, byte-identical to the pre-phase-95 result. Spec
#: rationale (pinned): 128 000 chars ≈ **32 000 tokens** at the
#: ~4-chars/token house estimate (``app.rag.llm``'s embed batching
#: notes ~3 chars/token for code-dense text, 4 for prose) — a quarter
#: of the 128k-token **minimum** context the owner's LLMs all have, so
#: a truncated read still leaves ~96k tokens for the system prompt, the
#: top-2 ``<documents>``, the tool rounds, and the 32 768-token answer
#: cap (``max_output_tokens``). Char-based (no tokenizer in the repo —
#: the ``BOR_SUMMARY_MAX_CHARS`` precedent) and env-tunable in both
#: directions. This is the ONLY truncated read path (owner permission
#: 2026-09-10, ``TODO.md`` L5): A7's never-truncated contract is for
#: the retrieval ``<documents>`` path, which stays whole.
read_max_chars: int = 128_000
# --- Hybrid retrieval (A7, revised 2026-08-21) --- # --- Hybrid retrieval (A7, revised 2026-08-21) ---
# cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion # cosine top-N ∪ Postgres FTS top-N, fused with Reciprocal Rank Fusion
@@ -274,6 +291,17 @@ class Settings(BaseSettings):
raise ValueError("agent_max_rounds must be >= 0 (0 = no tools)") raise ValueError("agent_max_rounds must be >= 0 (0 = no tools)")
return v return v
@field_validator("read_max_chars")
@classmethod
def _read_max_chars_non_negative(cls, v: int) -> int:
"""A negative cap is a typo — it would slice from the END of the
content (negative indexing) instead of failing. Fail loud at
startup (the ``agent_max_rounds`` pattern). ``0`` is legal (every
non-empty read truncates to the marker + notice)."""
if v < 0:
raise ValueError("read_max_chars must be >= 0 (chars)")
return v
@field_validator("llm_retries") @field_validator("llm_retries")
@classmethod @classmethod
def _llm_retries_non_negative(cls, v: int) -> int: def _llm_retries_non_negative(cls, v: int) -> int:
+115 -10
View File
@@ -86,8 +86,21 @@ task 04):
:data:`NOT_A_FOLDER` teaching (below) — :data:`NOT_A_FOLDER` teaching (below) —
``read`` takes the combined ``source/path`` string, splits it at the ``read`` takes the combined ``source/path`` string, splits it at the
FIRST ``'/'`` (source names are directory basenames — they can never FIRST ``'/'`` (source names are directory basenames — they can never
contain ``'/'``), and returns the document's **full** content contain ``'/'``), and returns the document's content — WHOLE at or
(A7-revised contract: never truncated) — and ``grep`` greps the under ``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``, default
128 000 chars ≈ 32k tokens), and CAPPED above it (phase 95, owner
permission 2026-09-10, ``TODO.md`` L5): the first ``read_max_chars``
chars plus the shared :data:`TRUNCATION_MARKER` and the pinned
:data:`READ_TRUNCATION_NOTICE` (the rest is NOT in the model's
context; ``grep`` — which searches the whole document — is the
follow-up), with the truncation recorded on the holder so the loop
yields a :class:`app.rag.llm.ToolResultPiece` (task 02 → the SSE
``tool_result`` frame + UI marker). **A7 scope clarification:** the
never-truncated contract is for the retrieval ``<documents>`` path
(the top-2 seed documents stay whole — "this should never happen");
the ``read`` TOOL path is the only capped read, per the owner's
explicit request — the two paths are distinct (retrieval seeds vs.
agent-requested additions). And ``grep`` greps the
indexed documents (or the one document a combined ``source/path`` indexed documents (or the one document a combined ``source/path``
names) for a case-insensitive fixed substring and returns up to 20 names) for a case-insensitive fixed substring and returns up to 20
``source/path:line: text`` match lines (owner-locked A5, phase 68), ``source/path:line: text`` match lines (owner-locked A5, phase 68),
@@ -212,7 +225,7 @@ import logging
import re import re
from collections.abc import AsyncIterator, Mapping, Sequence from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any, cast
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -227,8 +240,10 @@ from app.rag.llm import (
RetryPiece, RetryPiece,
StreamPiece, StreamPiece,
ToolCallPiece, ToolCallPiece,
ToolResultPiece,
chat_stream_retried, chat_stream_retried,
) )
from app.rag.retriever import TRUNCATION_MARKER
from app.rag.scaffolding import ScaffoldingFilter from app.rag.scaffolding import ScaffoldingFilter
from app.rag.source_removal import resolve_source_name from app.rag.source_removal import resolve_source_name
@@ -289,8 +304,14 @@ AGENT_TOOLS: list[dict[str, Any]] = [
"open or read it — its full text is already in your " "open or read it — its full text is already in your "
"prompt; answer directly from it. Use it only to add a " "prompt; answer directly from it. Use it only to add a "
"document NOT already in <documents> to your context, " "document NOT already in <documents> to your context, "
"by its combined `source/path` string. Call one tool at " "by its combined `source/path` string. Very large "
"a time — wait for this result before your next call." "documents are truncated: you receive the first part "
"plus a TRUNCATED notice naming how many more characters "
"exist — the notice is authoritative, the document did "
"NOT end where it stopped. Follow it and use `grep` "
"(pattern) to locate the rest — it searches the whole "
"document. Call one tool at a time — wait for this "
"result before your next call."
), ),
"parameters": { "parameters": {
"type": "object", "type": "object",
@@ -381,6 +402,25 @@ UNKNOWN_TOOL = "Unknown tool."
MISSING_READ_ARGS = "read requires a string argument 'path'." MISSING_READ_ARGS = "read requires a string argument 'path'."
MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'." MISSING_SEARCH_ARGS = "grep requires a string argument 'pattern'."
#: The read-truncation notice (phase 95, task 01) — appended, after the
#: shared :data:`TRUNCATION_MARKER`, to a ``read`` result whose document
#: is LONGER than ``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``,
#: default 128 000 chars ≈ 32k tokens). Two format fields: ``{total}``
#: (the document's true character count) and ``{shown}`` (the cap — how
#: many characters actually reached the model). The copy is pinned: it
#: tells the model the read was truncated, that the rest is NOT in its
#: context (the document did not end where it stopped), and names
#: ``grep`` — the phase-70 harness-aligned locator that searches the
#: WHOLE document — as the tool to find what it was looking for. A read
#: at or under the cap appends nothing (byte-identical to the
#: pre-phase-95 result).
READ_TRUNCATION_NOTICE = (
"TRUNCATED — this document is {total} characters; only the first "
"{shown} are in your context. The rest is NOT shown. Use grep "
"(pattern) to locate what you need — grep searches the whole "
"document."
)
#: The no-source ``ls`` refusal with the teaching parenthetical #: The no-source ``ls`` refusal with the teaching parenthetical
#: appended (phase 72): used when a stripped scope has no ``/`` and #: appended (phase 72): used when a stripped scope has no ``/`` and
#: matches no registered source (the incident's ``ls(path='.')``). The #: matches no registered source (the incident's ``ls(path='.')``). The
@@ -995,11 +1035,22 @@ class AgentHolder:
forced final + any recovery, phase 71) — drives the per-turn log forced final + any recovery, phase 71) — drives the per-turn log
line's ``scaffold_stripped=N`` field on grounded turns (the line's ``scaffold_stripped=N`` field on grounded turns (the
deflected path computes its own total in ``app.api.chat``). deflected path computes its own total in ``app.api.chat``).
``read_truncations`` (phase 95): one ``(argument, chars_shown,
chars_total)`` tuple per TRUNCATED ``read`` — the combined
``source/path`` the model passed, the cap kept
(``settings.read_max_chars``), and the document's true length — in
execution order. Recorded on truncation only; a read at or under the
cap appends nothing. A truncated read is still a **successful**
call: ``tool_calls`` increments as today and ``read_docs`` appends
as today — this list only carries the truncation signal the agent
loop turns into :class:`app.rag.llm.ToolResultPiece` values (task 02
surfaces them to the UI).
""" """
read_docs: list[Document] = field(default_factory=list) read_docs: list[Document] = field(default_factory=list)
tool_calls: int = 0 tool_calls: int = 0
scaffold_stripped: int = 0 scaffold_stripped: int = 0
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
def _execute_tool( def _execute_tool(
@@ -1007,6 +1058,7 @@ def _execute_tool(
call: ToolCallPiece, call: ToolCallPiece,
seed_docs: Sequence[Document], seed_docs: Sequence[Document],
holder: AgentHolder, holder: AgentHolder,
settings: Settings,
) -> str: ) -> str:
"""Execute one tool call server-side (DB only). """Execute one tool call server-side (DB only).
@@ -1016,7 +1068,13 @@ def _execute_tool(
it is a locator, locked A5); rejected calls return their refusal it is a locator, locked A5); rejected calls return their refusal
line and count in nothing. A grep that ran but found nothing is line and count in nothing. A grep that ran but found nothing is
still a successful (counted) call — its no-match line is a result, still a successful (counted) call — its no-match line is a result,
not a refusal. Document targets are combined ``source/path`` not a refusal. A ``read`` longer than ``settings.read_max_chars``
(phase 95) is cut at the cap and carries the shared
:data:`TRUNCATION_MARKER` + the pinned :data:`READ_TRUNCATION_NOTICE`
(the rest of the document is NOT in the model's context), and a
``(argument, cap, total)`` tuple is appended to
``holder.read_truncations`` (the signal the agent loop turns into a
:class:`app.rag.llm.ToolResultPiece`). Document targets are combined ``source/path``
strings, resolved by :func:`_resolve_path` (the canonical identity, strings, resolved by :func:`_resolve_path` (the canonical identity,
phase 70). phase 70).
""" """
@@ -1098,6 +1156,30 @@ def _execute_tool(
return _no_document_refusal(db, arg) return _no_document_refusal(db, arg)
holder.read_docs.append(doc) holder.read_docs.append(doc)
holder.tool_calls += 1 holder.tool_calls += 1
cap = settings.read_max_chars
if len(doc.content) > cap:
# Phase 95 (owner permission 2026-09-10, ``TODO.md`` L5): the
# read cap — cut at the cap and tell the model the truth: the
# shared marker + the pinned notice name how much more exists
# and point at ``grep`` (which searches the WHOLE document).
# A truncated read is still a successful call (both counters
# above already bumped); the holder tuple is the truncation
# signal only, so the loop can yield a ToolResultPiece.
# ``raw_path`` (a str here — ``arg`` is non-empty only when it
# was) is the exact argument the matching SSE ``tool`` frame
# carries, so the UI can match the two (task 02); the cast
# keeps pyright honest about the holder tuple's first element.
holder.read_truncations.append(
(cast("str", raw_path), cap, len(doc.content))
)
return (
f"Document {doc.source}/{doc.path}:\n"
f"{doc.content[:cap]}\n"
f"{TRUNCATION_MARKER}\n"
f"{READ_TRUNCATION_NOTICE.format(shown=cap, total=len(doc.content))}"
)
# At or under the cap: byte-identical to the pre-phase-95 result
# (no marker, no notice, no holder entry, no ToolResultPiece).
return f"Document {doc.source}/{doc.path}:\n{doc.content}" return f"Document {doc.source}/{doc.path}:\n{doc.content}"
if call.name == "grep": if call.name == "grep":
raw_pattern = call.arguments.get("pattern") raw_pattern = call.arguments.get("pattern")
@@ -1170,14 +1252,19 @@ async def run_agent(
settings: Settings, settings: Settings,
holder: AgentHolder, holder: AgentHolder,
history: Sequence[dict[str, Any]] = (), history: Sequence[dict[str, Any]] = (),
) -> AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece]: ) -> AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
"""Run the grounded-turn tool loop, yielding every stream piece. """Run the grounded-turn tool loop, yielding every stream piece.
Every piece (``thinking`` / ``content`` / tool calls / Every piece (``thinking`` / ``content`` / tool calls /
:class:`RetryPiece`) is yielded as it arrives; the API layer (task 04) :class:`RetryPiece`) is yielded as it arrives; the API layer (task 04)
turns tool-call pieces into SSE ``tool`` events and retry pieces into turns tool-call pieces into SSE ``tool`` events and retry pieces into
SSE ``retry`` events. After the loop finishes, *holder* carries the SSE ``retry`` events. A truncated ``read`` (phase 95 — the document is
read documents and the executed tool-call count (re-lists included). longer than ``settings.read_max_chars``) additionally yields one
:class:`ToolResultPiece` per truncated call, AFTER the round's
``tool`` frame and BEFORE the next model round (the API layer turns it
into an SSE ``tool_result`` frame, task 02). After the loop finishes,
*holder* carries the read documents, the executed tool-call count
(re-lists included), and the ``read_truncations`` signal list.
History (phase 74, TODO L4): *history* is the client's prior turns History (phase 74, TODO L4): *history* is the client's prior turns
already mapped to model messages by already mapped to model messages by
@@ -1350,7 +1437,12 @@ async def run_agent(
"to stream" "to stream"
) )
call = calls[0] # a stream can carry several calls; run the first call = calls[0] # a stream can carry several calls; run the first
result = _execute_tool(db, call, seed_docs, holder) # Phase 95: snapshot the truncation list BEFORE the execution so
# only the entries THIS call added are surfaced (one read per
# round, so at most one new entry — the loop still iterates the
# tail, so a future multi-call round stays correct).
trunc_before = len(holder.read_truncations)
result = _execute_tool(db, call, seed_docs, holder, settings)
rounds += 1 # every call the model emits consumes a round rounds += 1 # every call the model emits consumes a round
logger.info( logger.info(
"agent tool=%s args=%s round=%d/%d", "agent tool=%s args=%s round=%d/%d",
@@ -1376,6 +1468,19 @@ async def run_agent(
} }
) )
messages.append({"role": "tool", "tool_call_id": call.id, "content": result}) messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
# Phase 95: surface this round's truncation(s) to the API layer.
# The piece lands AFTER the round's ``tool`` frame (the matching
# ``ToolCallPiece`` was already streamed, above) and BEFORE the
# next model round (the loop continues below) — task 02 turns it
# into an SSE ``tool_result`` frame + the UI marker.
for argument, shown, total in holder.read_truncations[trunc_before:]:
yield ToolResultPiece(
name=call.name,
argument=argument,
truncated=True,
chars_shown=shown,
chars_total=total,
)
if rounds >= max_rounds: if rounds >= max_rounds:
logger.warning( logger.warning(
"agent round cap reached (rounds=%d) — forcing a final " "agent round cap reached (rounds=%d) — forcing a final "
+30
View File
@@ -89,6 +89,36 @@ class ToolCallPiece:
arguments: dict[str, Any] arguments: dict[str, Any]
@dataclass(frozen=True)
class ToolResultPiece:
"""One executed tool call whose result was truncated (phase 95).
A15 extension (owner permission 2026-09-10, ``TODO.md`` L5 — recorded
in the phase 95 overview ``00_phase.md``; PLAN.md is being redone by
the owner): the ``read`` tool caps its result at
``settings.read_max_chars`` (``BOR_READ_MAX_CHARS``, default 128 000
chars ≈ 32k tokens). When a document is longer than the cap, the agent
loop appends the shared ``[…truncated…]`` marker + the grep-pointer
notice to the result the model sees AND yields one of these pieces so
the API layer can surface the truncation to the user (an SSE
``tool_result`` frame, task 02). It is the ONLY piece the agent loop
yields that does not come from the model stream — it is derived from
the executed call. ``argument`` is the combined ``source/path`` the
model passed (the same value the matching ``tool`` frame carries),
``chars_shown`` is the cap (``settings.read_max_chars``) and
``chars_total`` is the document's true length — so the UI can render
"(truncated — showing N of M chars)". ``truncated`` is always ``True``
on a yielded piece (a non-truncated read yields nothing). Frozen like
its siblings: an immutable wire value.
"""
name: str # the tool that was executed (always "read" today)
argument: str | None # the model's argument (the combined source/path)
truncated: bool # always True on a yielded piece
chars_shown: int # the cap actually kept (settings.read_max_chars)
chars_total: int # the document's true length
@dataclass(frozen=True) @dataclass(frozen=True)
class RetryPiece: class RetryPiece:
"""One LLM request retry that is about to start (phase 67, locked A2). """One LLM request retry that is about to start (phase 67, locked A2).
+4 -1
View File
@@ -173,7 +173,10 @@ TOOLS_SECTION: str = (
"the <documents> section, even when the user asks you to open or " "the <documents> section, even when the user asks you to open or "
"read it — its full text is already in your prompt; answer " "read it — its full text is already in your prompt; answer "
"directly from it. For `read`, a bare document path (without the " "directly from it. For `read`, a bare document path (without the "
"source name) will not resolve. `grep` locates an exact string " "source name) will not resolve. Very large documents are capped: a "
"cut read returns the first part plus a TRUNCATED notice — the "
"document did not end where it stopped; use `grep` (pattern) to "
"find the rest, it searches the whole document. `grep` locates an exact string "
"(case-insensitive) in the indexed documents and returns up to 20 " "(case-insensitive) in the indexed documents and returns up to 20 "
"matching `source/path:line: text` lines — a locator, not a " "matching `source/path:line: text` lines — a locator, not a "
"context-adder: read the winner with `read`. A grep pattern is a " "context-adder: read the winner with `read`. A grep pattern is a "
+40
View File
@@ -153,6 +153,35 @@ class ChatToolEvent(BaseModel):
argument: str | None = None # the single string argument passed, or null argument: str | None = None # the single string argument passed, or null
class ChatToolResultEvent(BaseModel):
"""SSE frame for one executed tool call whose result was truncated
(phase 95, ``TODO.md`` L5).
A15 extension (owner permission 2026-09-10 — recorded in the phase 95
overview ``00_phase.md``; PLAN.md is being redone by the owner): the
SSE event-type list grows from six to SEVEN — ``thinking``,
``tool``, ``retry``, ``delta``, ``done``, ``error`` and this
optional ``tool_result``. The frame is strictly ADDITIVE: existing
frames and clients are untouched (a client that does not know the
type simply ignores it), and it is emitted ONLY for a truncated
``read`` — one frame per truncated read, carrying the counts the UI
renders as "(truncated — showing N of M chars)". It always follows
the matching :class:`ChatToolEvent` frame for the same call (the
line is already on screen; the marker lands a beat later — the
phase-37/48 tool-line lifecycle is untouched). ``argument`` is the
combined ``source/path`` the model passed (identical to the matching
``tool`` frame's argument, so the client can match the two); a
non-truncated read streams NO frame of this type.
"""
type: str = "tool_result"
name: str # the tool that was executed (always "read" today)
argument: str | None = None # the model's argument (combined source/path)
truncated: bool = True # always True on a sent frame (the emission trigger)
chars_shown: int = Field(ge=0) # the cap kept (settings.read_max_chars)
chars_total: int = Field(ge=0) # the document's true length
class ChatDoneEvent(BaseModel): class ChatDoneEvent(BaseModel):
"""Final SSE event of a chat turn: metadata for the finished answer.""" """Final SSE event of a chat turn: metadata for the finished answer."""
@@ -456,10 +485,21 @@ class ToolCall(BaseModel):
``AGENT_TOOLS`` names are short) and ``argument`` ≤ 2000 (the ``AGENT_TOOLS`` names are short) and ``argument`` ≤ 2000 (the
combined ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is 2× combined ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is 2×
headroom for a grep pattern). headroom for a grep pattern).
Phase 95 (task 02): the truncation marker the UI renders next to the
Reading line rides the SAME record — ``truncated`` (default False:
the pre-phase-95 shape) + the two non-negative counts. Small
additive fields with defaults, no migration (``ChatMessage.tools``
is JSON) — a saved chat written before phase 95 (no fields) validates
UNCHANGED (the phase-50 backward-compat rule) and renders without
the marker.
""" """
name: str = Field(max_length=100) name: str = Field(max_length=100)
argument: str | None = Field(default=None, max_length=2000) argument: str | None = Field(default=None, max_length=2000)
truncated: bool = False
chars_shown: int | None = Field(default=None, ge=0)
chars_total: int | None = Field(default=None, ge=0)
#: One suggestion chip (phase 83, A1): a short deterministic string — #: One suggestion chip (phase 83, A1): a short deterministic string —
+79
View File
@@ -80,6 +80,19 @@
* optional `tools: [{name, argument}]` array next to `thinking` and * optional `tools: [{name, argument}]` array next to `thinking` and
* restore re-renders the lines (phase 14 convention). * restore re-renders the lines (phase 14 convention).
* *
* Truncated reads (phase 95, A15 extension — `tool_result` is the
* seventh, optional SSE event type; existing frames untouched, unknown
* types ignored): a `read` longer than BOR_READ_MAX_CHARS streams ONE
* `tool_result` frame after its `tool` frame, and the handler appends
* the " (truncated — showing N of M chars)" marker to that Reading
* line (appendTruncatedNote — DOM append, never a re-render) AND stamps
* the matching toolAcc entry (newest, same argument) with `truncated` /
* `chars_shown` / `chars_total` — the save payload carries the record
* with zero other change, and BOTH restore paths (the phase-14 local
* renderStoredMessage and the shared page's addToolLines) re-render the
* same marker from the stored record (pixel-identical, the phase-50
* restore contract). A non-truncated read streams no frame at all.
*
* LLM retry status (phase 67, TODO.md L3): if the endpoint dies BEFORE * LLM retry status (phase 67, TODO.md L3): if the endpoint dies BEFORE
* the first frame of an LLM request lands, the server restarts that * the first frame of an LLM request lands, the server restarts that
* request (up to BOR_LLM_RETRIES retries, BOR_LLM_RETRY_DELAY seconds * request (up to BOR_LLM_RETRIES retries, BOR_LLM_RETRY_DELAY seconds
@@ -959,6 +972,34 @@ function appendToolLine(wrap, name, argument) {
container.appendChild(line); container.appendChild(line);
} }
/* 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
* (textContent carries data, never markup) — so the newest `.tool-call`
* line whose code child holds that argument is the target (one line per
* call, phase 37/48 — the marker APPENDS a span sibling, it never
* rewrites the line's pinned template text). createElement + textContent
* only — the house "this file never builds HTML" rule (no innerHTML).
* A frame for a line that is no longer in the DOM (New Chat mid-turn)
* is a silent no-op — the persisted record still carries the counts.
* Pinned marker copy (unit + E2E assertion target): " (truncated —
* showing N of M chars)" — plain integers, no thousands separators. */
function appendTruncatedNote(wrap, argument, charsShown, charsTotal) {
const calls = wrap?.querySelector?.(".tool-calls");
if (!calls) return;
const lines = calls.querySelectorAll(".tool-call");
for (let i = lines.length - 1; i >= 0; i -= 1) {
const code = lines[i].querySelector("code");
if (!code || code.textContent !== argument) continue;
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + charsShown + " of " + charsTotal + " chars)";
lines[i].appendChild(note);
return;
}
}
/* ---------- suggestions (shared chip component, phase 05) ---------- /* ---------- suggestions (shared chip component, phase 05) ----------
* *
* One component, two homes: the onboarding row in the empty state and the * One component, two homes: the onboarding row in the empty state and the
@@ -1442,11 +1483,18 @@ function renderStoredMessage(m) {
if (Array.isArray(m.tools)) { if (Array.isArray(m.tools)) {
// Phase 37: restore the tool lines in saved order through the SAME // Phase 37: restore the tool lines in saved order through the SAME
// append helper as the live frames (no HTML from storage, ever). // append helper as the live frames (no HTML from storage, ever).
// Phase 95: a stored truncation record (truncated + the counts, the
// live tool_result frame's stamp) re-renders the SAME marker next to
// its Reading line — old records without the field render
// unchanged (t.truncated falsy → no marker).
for (const t of m.tools) { for (const t of m.tools) {
if (!t || typeof t.name !== "string") continue; if (!t || typeof t.name !== "string") continue;
const arg = const arg =
typeof t.argument === "string" && t.argument ? t.argument : null; typeof t.argument === "string" && t.argument ? t.argument : null;
appendToolLine(wrap, t.name, arg); appendToolLine(wrap, t.name, arg);
if (t.truncated && arg) {
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
}
} }
} }
if (m.deflected) { if (m.deflected) {
@@ -2320,6 +2368,37 @@ async function runTurn(text, { reask = false } = {}) {
}, leavePartialIndex); }, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer lastBrainWrap = wrap; // this bubble is now the last brain answer
markLastRetryable(); // phase 49: the Retry button is last-bubble-only markLastRetryable(); // phase 49: the Retry button is last-bubble-only
} else if (ev.type === "tool_result") {
// Phase 95 (A15 extension, task 02): the truncation the LLM is
// told about is told to the USER. One frame per truncated read,
// always after its `tool` frame and before the next round — the
// Reading line is already on screen. Settle that line's
// elapsed clock like every other frame (the marker is the
// visible feedback now), then append the marker to the NEWEST
// line carrying this argument (appendTruncatedNote — a DOM
// append to the existing line: no new line, no re-render, the
// phase-37/48 tool-line lifecycle is untouched) and stamp the
// matching toolAcc entry so the save payload carries it (the
// `done` save point below needs zero other change). A frame
// whose line/toolAcc entry is gone (New Chat mid-turn) is a
// silent no-op; a non-truncated read never sends one.
settleToolLine();
const argument =
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
const shown = Number(ev.chars_shown) || 0;
const total = Number(ev.chars_total) || 0;
if (argument && ev.truncated) {
appendTruncatedNote(wrap, argument, shown, total);
for (let i = toolAcc.length - 1; i >= 0; i -= 1) {
const t = toolAcc[i];
if (t && t.argument === argument) {
t.truncated = true;
t.chars_shown = shown;
t.chars_total = total;
break;
}
}
}
} else if (ev.type === "error") { } else if (ev.type === "error") {
throw new Error(ev.detail || "Something went wrong on my side."); throw new Error(ev.detail || "Something went wrong on my side.");
} }
+21 -1
View File
@@ -153,7 +153,17 @@ function addThinkingBlock(wrap, thinking) {
* line (no migration). The content marks are the exact app.js * line (no migration). The content marks are the exact app.js
* template strings — the frontend emoji guard (tests/integration/ * template strings — the frontend emoji guard (tests/integration/
* test_api.py) strips precisely those literals in this file, as in * test_api.py) strips precisely those literals in this file, as in
* app.js. */ * app.js.
*
* Phase 95 (task 02): the truncation marker rides the SAME stored
* record the chat page uses — a {name, argument, truncated,
* chars_shown, chars_total} entry (the live `tool_result` frame's stamp,
* persisted with the turn) re-renders the identical " (truncated —
* showing N of M chars)" span next to its Reading line, so a shared
* page shows the truncation pixel-identically to the chat page (the
* phase-50 restore contract). A record saved before phase 95 (no
* fields) renders exactly as before (no marker, no migration).
* createElement + textContent only — nothing HTML-shaped from storage. */
function addToolLines(wrap, tools) { function addToolLines(wrap, tools) {
if (!Array.isArray(tools) || !tools.length) return; if (!Array.isArray(tools) || !tools.length) return;
const body = wrap.querySelector(".msg-body"); const body = wrap.querySelector(".msg-body");
@@ -192,6 +202,16 @@ function addToolLines(wrap, tools) {
line.textContent = "🔎 Listing documents"; line.textContent = "🔎 Listing documents";
} }
container.appendChild(line); container.appendChild(line);
// Phase 95: the stored truncation record — the same marker the chat
// page's restore path renders (plain integers, no separators);
// only argument-bearing (Reading) lines can carry it.
if (t.truncated && argument) {
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + (Number(t.chars_shown) || 0) + " of " + (Number(t.chars_total) || 0) + " chars)";
line.appendChild(note);
}
} }
body.insertBefore(container, body.querySelector(".bubble")); body.insertBefore(container, body.querySelector(".bubble"));
} }
+10
View File
@@ -644,6 +644,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
margin-left: 0.5rem; margin-left: 0.5rem;
white-space: nowrap; white-space: nowrap;
} }
/* Phase 95 (task 02): the truncation marker on a Reading line — the
" (truncated — showing N of M chars)" span the `tool_result` frame
appends (live) or the restore/shared paths re-render from the stored
record. Theme-neutral, NO new hue (the phase-92 zero-literal
invariant): it borrows --ink-soft — the same AA-safe soft-ink the
status suffixes use — so under phase 93's monochrome theme it grays
automatically, and the marker stays TEXT, never color alone (B5). */
.tool-call .truncated-note {
color: var(--ink-soft);
}
.msg-meta { .msg-meta {
font-size: 0.75rem; font-size: 0.75rem;
+129
View File
@@ -259,6 +259,34 @@ Implements just enough of the aipi surface:
the ``<tools>`` section, so deflected turns never hit it); no the ``<tools>`` section, so deflected turns never hit it); no
existing E2E question or fixture file contains the phrase, so existing E2E question or fixture file contains the phrase, so
every other suite is unaffected. every other suite is unaffected.
- user message containing ``read the capped document``
(``READ_CAP_TRIGGER``, phase 95 task 03 — the read cap's dedicated
story suite ``tests/e2e/test_read_truncation_cap.py``) **and** the
system prompt carries the ``<tools>`` section -> the deterministic
SCRIPTED CAPPED-READ flow: the question carries its own tool call
after the colon — ``read the capped document: read source/path`` —
parsed by ``_READ_CAP_CALL_RE`` from the RAW user message (the
target keeps its case), then discriminated statelessly from the
tool results (streaming only):
* request 1 (``tools`` offered, no ``tool``-role result in the
messages yet): the scripted call — ``read`` with the parsed
target (synthetic id ``call_0``);
* a ``tool``-role result is in the messages: the deterministic
ECHO — the answer carries the LAST tool result VERBATIM
(``Here's what the read returned:\n<result>``): a read result
(``"Document <source/path>:…``) lands in the answer with its
FULL content — the first cap chars + ``[…truncated…]`` + the
pinned grep-pointer notice when the cap fired, the plain body
byte-identical to the pre-phase-95 shape when it did not — and
a refusal (the premise broke) lands just as visibly, so the
suite fails loudly on it. The mock is the only E2E lens on the
LLM's context, so the echo is the assertion surface for both
the marker's presence AND its absence.
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
phrases — the phase-71/72/94 ordering convention; the trigger
needs the ``<tools>`` section, so deflected turns never hit it);
verified 2026-09-10: no existing E2E question or fixture file
contains the phrase, so every other suite is unaffected.
- user message containing ``what are the correct llama.cpp - user message containing ``what are the correct llama.cpp
arguments`` (``GREP_TEACH_TRIGGER``, the 2026-09-05 incident — arguments`` (``GREP_TEACH_TRIGGER``, the 2026-09-05 incident —
the harness prior is that grep takes a REGEX; this app's grep is a the harness prior is that grep takes a REGEX; this app's grep is a
@@ -1293,6 +1321,79 @@ def _drill_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("echo", last) return ("echo", last)
# ---------------------------------------------------------------------------
# Phase 95 (task 03, the read cap's dedicated story suite):
# the deterministic SCRIPTED capped read — see the module docstring
# ---------------------------------------------------------------------------
#: A user message containing this substring (case-insensitive) —
#: combined with the ``<tools>`` section in the system prompt — drives
#: the scripted CAPPED-READ flow (the read cap's story suite,
#: ``tests/e2e/test_read_truncation_cap.py``): the question carries its
#: own tool call after the colon — ``read the capped document: read
#: source/path`` — the mock emits the scripted ``read``, then ECHOES
#: the ENTIRE tool result into its answer (the house scripted-turn lens
#: on the LLM's context: the truncated shape — first cap chars +
#: ``[…truncated…]`` + the pinned grep-pointer notice — or the plain
#: shape, byte-identical to the pre-phase-95 result, lands in the
#: rendered answer, and the suite asserts both directions through it).
#: Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
#: phrases — the phase-71/72/94 ordering convention); verified
#: 2026-09-10: no existing E2E question or fixture file contains the
#: phrase, so every other suite is unaffected.
READ_CAP_TRIGGER = "read the capped document"
#: The scripted call in the read-cap question (case-insensitive — the
#: suite's questions capitalize 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 the suite's `` — ``
#: flavor separator (em dash) can never bleed into it (the drill-down
#: convention, ``_DRILL_CALL_RE``).
_READ_CAP_CALL_RE = re.compile(
r"read the capped document:\s*read\s+(?P<arg>[a-z0-9_./-]+)",
re.I,
)
def _read_cap_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-95 scripted read-cap request (see the module
docstring). The question carries the scripted call (``read the
capped document: read source/path``); the step is then discriminated
statelessly from the tool results, like the other marker flows:
* ``("call", target, "call_0")`` — ``tools`` are offered and no
``tool``-role result is in the messages yet: the scripted
``read`` on the parsed target (synthetic id ``call_0``).
* ``("echo", result)`` — a ``tool``-role result is in the messages:
the deterministic ECHO — the answer carries the LAST tool result
VERBATIM (``Here's what the read returned:\n<result>``): a read
result (``"Document <source/path>:…``) lands in the answer with
its full content — the truncation marker + the pinned notice when
the cap fired, the plain body when it did not — and a refusal
(the premise broke) lands just as visibly, so the suite fails
loudly on it.
* ``None`` — not the flow: the trigger is absent, the ``<tools>``
section is missing (deflected turns never carry it), the scripted
call is unparseable, or ``tools`` are not offered and no tool
results are in the messages yet (e.g. ``agent_max_rounds=0``).
"""
user = _user(body)
if READ_CAP_TRIGGER not in user.lower():
return None
if "<tools>" not in _system(body):
return None
match = _READ_CAP_CALL_RE.search(user)
if match is None:
return None
results = _tool_results(body)
if not results:
if not body.get("tools"):
return None
return ("call", match.group("arg"), "call_0")
return ("echo", results[-1])
def long_answer() -> str: def long_answer() -> str:
"""~900-word deterministic walkthrough (phase 11): numbered steps plus """~900-word deterministic walkthrough (phase 11): numbered steps plus
a unique final line that must survive the stream untruncated.""" a unique final line that must survive the stream untruncated."""
@@ -1985,6 +2086,34 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream", media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
) )
# Phase 95 (task 03): the deterministic SCRIPTED capped read
# (the question carries its own call — ``read the capped
# document: read source/path``): the scripted ``read``, then the
# answer that ECHOES the whole tool result (the marker + notice
# when the cap fired, the plain shape when it did not — the
# suite's lens on the LLM's context). Checked BEFORE the plain
# TOOLS_TRIGGER flow (disjoint trigger phrases — the
# phase-71/72/94 ordering convention; the trigger needs the
# ``<tools>`` section, so deflected turns never hit it).
read_cap = _read_cap_flow(body)
if read_cap is not None:
if read_cap[0] == "call":
stream = _tool_call_stream(
"read", {"path": read_cap[1]}, read_cap[2]
)
else: # "echo" — the last tool result verbatim (the lens)
stream = _sse_stream(
_apply_max_tokens(
f"Here's what the read returned:\n{read_cap[1]}",
body.get("max_tokens"),
),
0.0,
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
flow = _tool_flow(body) flow = _tool_flow(body)
if flow is not None: if flow is not None:
if flow[0] == "list": if flow[0] == "list":
+861
View File
@@ -0,0 +1,861 @@
"""Phase 95 task 03 E2E (Playwright, mock-only): the read cap's
dedicated story suite — the truncated read, end to end.
The whole TODO.md L5 item (``95_read_truncation_cap`` task 03 is the
story gate): a document over the (lowered) cap is read truncated, the
``tool_result`` frame lands, the Reading line carries the marker, the
LLM's context carried ``[…truncated…]`` + the grep pointer, and the
marker survives save → shared.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the real
``turbo`` does whatever it does with the tools, while this story's gate
is the deterministic SCRIPTED capped-read flow in
``tests/e2e/mock_llm.py`` (``READ_CAP_TRIGGER``: user message contains
``read the capped document`` — the question carries its own tool call
after the colon, ``read the capped document: read source/path`` —
**and** the system prompt carries the ``<tools>`` section of the HIGH
prompt). The mock ECHOES the ENTIRE read tool result into its final
answer (the house scripted-turn way of asserting on tool results — the
mock is the only E2E lens on the LLM's context), so both the marker's
presence (the truncated turn) and its absence (the short-document
control) land on the rendered answer.
The app under test boots with the LOWERED cap (the
env-override-for-the-app-under-test pattern — this suite's module app,
as in ``test_local_directory_sources.py`` / ``test_ls_tree_drilldown.py``):
``BOR_READ_MAX_CHARS=1500`` — small enough that the ~3 100-char
fixture document truncates deterministically at 1 500, big enough that
the short-document control (~200 chars) never does.
KB fixture — one host temp dir (``tmp_path_factory``; the app runs on
the same host) registered as a local-directory source (the
``test_local_directory_sources.py`` registration + real-Sync pattern —
registration through the authenticated API, the real in-process
``POST /api/sync`` pipeline; no git anywhere), with THREE documents
whose bodies are token-controlled so the phase-72 ALREADY_IN_CONTEXT
dedupe (the read target is refused when it is a top-2 retrieval seed)
NEVER fires — each scripted read target must actually execute, not be
refused:
* ``anchor-2024.md`` — the retrieval ANCHOR: a digit-bearing name.
Both questions name "anchor 2024", whose joined normalized token
(``anchor2024``) name-hits this document — the name-hit list LEADS
the lexical side, so the anchor is the #1 seed of BOTH turns
(grounding: the name hit's ``fts_hit`` keeps the gate HIGH, the
``<tools>`` section is present, and the anchor is never a read
target);
* ``zz-capped.md`` — the SUBJECT: ~3 100 chars of varied rotation
prose (deterministic, pinned below with its exact length — the
``chars_total`` the assertions use is ``len(CAP_DOC)`` of this very
string, and ``synced_kb`` pins the stored content byte-identical to
it). Its body avoids EVERY token of both questions, so on its own
(turn 1) question it has zero lexical hits and only the common-word
cosine — it is the #3 fused candidate, NOT a seed;
* ``aa-short.md`` — the CONTROL: ~200 chars, under the cap.
The turn-specific flavor words pick the #2 seed: turn 1's question
carries ``note, long form`` (planted in the SHORT doc's body) and turn
2's carries ``quick pass`` (planted in the CAPPED doc's body), so each
turn's NON-target document wins the second seed slot on its own
question and the target stays #3/#6. ``synced_kb`` pins this design
with the app's real hybrid retrieval (``_assert_target_not_seed`` — a
fixture-text regression that makes a target a seed fails at setup with
a clear message, not at the wire assertions).
Test → story mapping (Playwright Mapping Rule; the story is the owner
TODO item — one Playwright file per story, A16):
1. ``test_truncated_read_frame_order_live_marker_and_llm_notice`` —
the wire carries the ``tool`` frame THEN exactly one
``tool_result`` frame (the pinned counts) THEN the answer's
``delta``/``done`` frames; the live Reading line carries the pinned
``(truncated — showing 1500 of N chars)`` marker; the mock's echo
proves the LLM context carried ``[…truncated…]`` AND the pinned
``TRUNCATED — … Use grep …`` notice (and the head sentinel reached
the model while the past-the-cap tail sentinel did NOT).
2. ``test_truncated_read_save_and_shared_fidelity`` — the auto-saved
row's (phase 55) brain message ``tools`` record carries
``truncated: true`` + the counts (saved-chats API); the shared
page (``/shared/<token>``) renders the SAME marker on the Reading
line (the phase-50 restore contract, pixel-identical).
3. ``test_short_read_control_no_frame_no_marker`` — the under-cap read
executes (its ``tool`` frame lands) but streams NO ``tool_result``
frame, the Reading line carries NO marker, the echo shows no
``[…truncated…]`` / ``TRUNCATED —``, and the saved ``tools``
record is the plain pre-truncation shape.
"""
from __future__ import annotations
import json
import os
import re
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 Browser, BrowserContext, 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 app.rag.agent import READ_TRUNCATION_NOTICE
from app.rag.retriever import TRUNCATION_MARKER
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from e2e.mock_llm import embed_text
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_READCAP", "8137"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The lowered read cap the app under test boots with
# (``BOR_READ_MAX_CHARS``): the subject of the suite.
READ_CAP = 1500
SOURCE = "capkb" # the local directory's basename = the source name
ANCHOR_REL = "anchor-2024.md"
CAPPED_REL = "zz-capped.md"
SHORT_REL = "aa-short.md"
CAPPED_SP = f"{SOURCE}/{CAPPED_REL}" # the combined read identity
SHORT_SP = f"{SOURCE}/{SHORT_REL}"
# --------------------------------------------------------------------------
# Fixture documents (deterministic, token-controlled — see the module
# docstring for the retrieval-anchor design)
# --------------------------------------------------------------------------
ANCHOR_DOC = (
"# Anchor 2024\n\n"
"The anchor 2024 record keeps the backup rotation steady: the quiet "
"window, the mirror pool, and the retention label all stay pinned to "
"this year's plan.\n"
)
HEAD_SENTINEL = "CAPDOC-HEAD-9f2a" # inside the first 1 500 chars
TAIL_SENTINEL = "CAPDOC-TAIL-7c3b" # PAST the cap (the rest is NOT shown)
SHORT_SENTINEL = "CAPSHORT-5d1e" # the whole short doc is under the cap
def _cap_doc() -> str:
"""The ~3 100-char subject document: a repeated-but-varied paragraph
block (deterministic), the head sentinel near the top, the planted
``quick pass`` sentence (the turn-2 flavor words — see the module
docstring) and the tail sentinel at the very end. The body avoids
every token of BOTH questions (``read``, ``capped``, ``document``,
``capkb``, ``zz``, ``md``, ``anchor``, ``2024``, ``note``,
``long``, ``form``, ``aa``, ``short``, ``quick``, ``pass``,
``wire``, ``save``, ``control``, ``check``) — the target must have
zero lexical hits on its own turn so the dedupe cannot refuse the
read."""
topics = [
"vault", "mirror", "raid", "pool", "drive",
"chain", "slot", "cycle", "guard", "probe",
]
paras: list[str] = []
i = 0
while True:
t = topics[i % len(topics)]
paras.append(
f"{t.title()} item {i:02d}: at 02:00 the quiet window opens and "
f"the archive job copies the {t} image to the mirror pool, then "
f"the manifest audit verifies the snapshot chain for slot {i:02d}, "
"keeping the retention label clean and the rotation order exact "
"for the whole night cycle."
)
i += 1
if len("\n\n".join(paras)) > 2750:
break
head = (
"# Rotation archive\n\n"
f"{HEAD_SENTINEL}\n\n"
"A quick pass over the rotation confirms the pass order of the "
"guard probe before the quiet window closes, and the audit log "
"records the result.\n\n"
)
tail = f"\n\n{TAIL_SENTINEL}\n"
return head + "\n\n".join(paras) + tail
CAP_DOC = _cap_doc()
CAP_DOC_TOTAL = len(CAP_DOC)
SHORT_DOC = (
"# Rotation plan\n\n"
"This note keeps the long form of the rotation plan in one place: the "
"quiet window, the mirror pool, the retention label, and the guard "
"probe order for every weekly run.\n\n"
f"{SHORT_SENTINEL}\n"
)
SHORT_DOC_TOTAL = len(SHORT_DOC)
# The suite's length contract (the task's pinned-count assumption —
# ``chars_total`` is asserted against ``len(CAP_DOC)`` of the very
# string written to the fixture, and ``synced_kb`` pins the stored
# content byte-identical to it): the subject is OVER the cap with the
# tail sentinel past the cut; the control is UNDER the cap.
assert CAP_DOC_TOTAL > READ_CAP, CAP_DOC_TOTAL
assert CAP_DOC.index(HEAD_SENTINEL) < READ_CAP < CAP_DOC.index(TAIL_SENTINEL)
assert CAP_DOC.count(HEAD_SENTINEL) == 1 and CAP_DOC.count(TAIL_SENTINEL) == 1
assert SHORT_DOC_TOTAL < READ_CAP, SHORT_DOC_TOTAL
assert SHORT_DOC.count(SHORT_SENTINEL) == 1
# The pinned marker copy (task 02 — the app.js/shared.js template, plain
# integers, no thousands separators) and the LLM-side notice (the
# app.rag.agent constant, formatted for this fixture's counts).
MARKER = f" (truncated — showing {READ_CAP} of {CAP_DOC_TOTAL} chars)"
NOTICE = READ_TRUNCATION_NOTICE.format(shown=READ_CAP, total=CAP_DOC_TOTAL)
# The scripted turns (the mock's ``READ_CAP_TRIGGER`` questions — each
# carries its own tool call after the colon; the turn-specific flavor
# words are the #2-seed selectors, see the module docstring).
Q_WIRE = (
f"Read the capped document: read {CAPPED_SP} — "
"anchor 2024 note, long form, wire check"
)
Q_SAVE = (
f"Read the capped document: read {CAPPED_SP} — "
"anchor 2024 note, long form, save check"
)
Q_CONTROL = (
f"Read the capped document: read {SHORT_SP} — "
"anchor 2024, quick pass, control check"
)
#: The invalid/unknown share token shape (test_share_chat.py's
#: ``SHARE_URL_RE`` convention).
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
# --------------------------------------------------------------------------
# Fixtures
# --------------------------------------------------------------------------
@pytest.fixture(scope="module")
def cap_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 three token-controlled fixture documents. The directory's
basename is the source name (``kind=local``, phase 38)."""
root = tmp_path_factory.mktemp("bor_read_cap") / SOURCE
root.mkdir()
for rel, doc in (
(ANCHOR_REL, ANCHOR_DOC),
(CAPPED_REL, CAP_DOC),
(SHORT_REL, SHORT_DOC),
):
(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, cap_dirs: Path) -> Iterator[str]:
"""The real app under test — per-module env (the conftest pattern,
cf. ``test_ls_tree_drilldown.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 threshold, the
leak-guarded code defaults — and the SUBJECT: the lowered read cap
``BOR_READ_MAX_CHARS=1500`` (the env-override-for-the-app-under-
test pattern; the default 128 000 is unit-pinned in
``tests/unit/test_config.py``). 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 threshold (conftest pattern) — the retrieval-anchor
# design keeps every scripted turn grounded regardless.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
# 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 BOR_GIT_SOURCES (the app
# reads it from cwd) — override it with an EMPTY value (the env var
# beats the .env file): the registry must hold EXACTLY the local
# directory this suite registers (a leftover env git list would
# pollute the KB the scripted reads run against).
env["BOR_GIT_SOURCES"] = ""
# Leak guards (conftest pattern): an operator's local (gitignored)
# .env cannot leak corpus-specific settings into the app under test.
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 lowered read cap (task 03 — the app under test
# boots with BOR_READ_MAX_CHARS=1500; the production default of
# 128 000 stays pinned in tests/unit/test_config.py).
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 anchor
design's margins are pinned against EXACTLY these three
documents)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries"
)
)
db.commit()
def _assert_target_not_seed(question: str, target_rel: str) -> None:
"""Pin the retrieval-anchor design (see the module docstring) with
the app's REAL hybrid retrieval over the mock's embeddings
(deterministic): the scripted read target must NOT be a top-2 seed
for its own question — the phase-72 ALREADY_IN_CONTEXT dedupe would
refuse the read and the cap would never fire (the turn would echo
the refusal instead). A fixture-text regression that breaks this
fails here, at setup, with a clear message."""
from app.rag.retriever import retrieve, select_documents
with SessionLocal() as db:
seeds = select_documents(retrieve(db, question, embed_text(question)))
paths = [f"{d.source}/{d.path}" for d in seeds]
assert f"{SOURCE}/{target_rel}" not in paths, (
f"the read target {SOURCE}/{target_rel} is a top-2 seed for its own "
f"question — the ALREADY_IN_CONTEXT dedupe would refuse the read and "
f"the cap would never fire (seeds: {paths})"
)
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, cap_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), pins the stored content
byte-identical to the fixture strings (the ``chars_total``
assumption), and pins the retrieval-anchor design for all three
scripted questions (the dedupe never fires)."""
_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(cap_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"] == 3, detail
assert detail["pruned"] == 0, detail
# The import stored the fixture strings BYTE-IDENTICALLY — the
# ``chars_total`` every assertion below uses is ``len()`` of the
# very string written to the directory (the task's pinned-count
# assumption).
with SessionLocal() as db:
for rel, expected in (
(ANCHOR_REL, ANCHOR_DOC),
(CAPPED_REL, CAP_DOC),
(SHORT_REL, SHORT_DOC),
):
stored = db.scalar(
select(Document.content).where(
Document.source == SOURCE, Document.path == rel
)
)
assert stored == expected, f"stored content drifted for {rel}"
# The retrieval-anchor design (the module docstring): every scripted
# read target stays OUT of its own question's top-2 seeds.
_assert_target_not_seed(Q_WIRE, CAPPED_REL)
_assert_target_not_seed(Q_SAVE, CAPPED_REL)
_assert_target_not_seed(Q_CONTROL, SHORT_REL)
@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 helpers (the test_ls_tree_drilldown / test_share_chat 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`` / ``tool_result`` 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) -> list[dict]:
"""The SSE frames captured since the last submit (``_submit``
clears the buffer), once the hook's background read settles."""
deadline = time.monotonic() + 30.0
while True:
raw = page.evaluate("() => window.__sseFrames || []")
parsed = [json.loads(line) for line in raw if line]
if any(f.get("type") == "done" for f in parsed):
return parsed
if time.monotonic() > deadline:
raise AssertionError(
f"SSE hook captured no `done` frame (frames so far: "
f"{len(parsed)}) — hook install failed?"
)
time.sleep(0.05)
def _tool_frames(frames: list[dict]) -> list[dict]:
return [f for f in frames if f.get("type") == "tool"]
def _result_frames(frames: list[dict]) -> list[dict]:
return [f for f in frames if f.get("type") == "tool_result"]
def _submit(page: Page, question: str) -> None:
page.evaluate("window.__sseFrames = []")
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page) -> None:
"""The turn is complete: answer text in the bubble, button recovered
(the phase-48 settle wait, the test_agent_document_tools helper)."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text("", timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
def _last_brain(page: Page) -> Locator:
return page.locator(".msg.brain").last
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 _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 (the test's API side
sees exactly what the signed-in browser sees)."""
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 _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 _get_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> dict[str, Any]:
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200, r.text
return r.json()
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 —
A2 — so there is no status line to wait on). The upsert is
fire-and-forget from the UI's point of view, so 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})")
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions on the admin context
(test_share_chat.py — the assertion branches on the API's
availability, so a non-secure origin still passes through the
fallback branch deterministically)."""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _click_share_and_assert_status(page: Page, app_url: str) -> None:
"""Press the chat page's Share pill and pin the owner-locked
outcome on the live region (test_share_chat.py's helper, verbatim
contract): "Share link copied." when ``navigator.clipboard`` is
available in the context, else the inline fallback link field
carrying the ``/shared/<uuid>`` URL."""
page.locator("#share-chat-btn").click()
if page.evaluate("() => !!navigator.clipboard"):
expect(page.locator("#send-status")).to_have_text(
"Share link copied.", timeout=15_000
)
expect(page.locator(".share-link-fallback")).to_have_count(0)
else:
expect(page.locator("#send-status")).to_have_text(
"Share link ready — copy it from the field.", timeout=15_000
)
field = page.locator(".share-link-fallback")
expect(field).to_be_visible()
href = field.get_attribute("href")
assert href is not None
assert href.startswith(app_url)
assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url))
# --------------------------------------------------------------------------
# 1. The truncated read: the wire's frame order, the live marker, the
# LLM-visible notice (via the mock's echo)
# --------------------------------------------------------------------------
def test_truncated_read_frame_order_live_marker_and_llm_notice(
page: Page, app_url: str, synced_kb: None, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_WIRE)
_wait_settled(page)
# --- the wire: tool → tool_result → delta… (exactly one, counts) --
frames = _frames(page)
assert _tool_frames(frames) == [
{"type": "tool", "name": "read", "argument": CAPPED_SP}
], _tool_frames(frames)
# EXACTLY ONE tool_result frame — the pinned counts (the char-based
# cap: showing the first READ_CAP of the fixture's true length).
assert _result_frames(frames) == [
{
"type": "tool_result",
"name": "read",
"argument": CAPPED_SP,
"truncated": True,
"chars_shown": READ_CAP,
"chars_total": CAP_DOC_TOTAL,
}
], _result_frames(frames)
# Frame order: the marker lands AFTER the call is shown and BEFORE
# the answer's first delta (the phase-37/48 "calling tool" timing
# is untouched — the beat between the two is the truncation).
i_tool = next(i for i, f in enumerate(frames) if f.get("type") == "tool")
i_result = next(
i for i, f in enumerate(frames) if f.get("type") == "tool_result"
)
i_delta = next(i for i, f in enumerate(frames) if f.get("type") == "delta")
assert i_tool < i_result < i_delta, (i_tool, i_result, i_delta)
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 even though it was
# NOT a retrieval seed (the anchor design) — retrieval + agent-read,
# deduped (the grounded-turn record).
assert any(
s["path"] == CAPPED_REL and s["source"] == SOURCE for s in done["sources"]
), done["sources"]
# --- the live DOM: the Reading line carries the pinned marker ----
line = _last_brain(page).locator(".tool-call")
expect(line).to_have_count(1)
expect(line).to_contain_text(f"Reading {CAPPED_SP}")
expect(line.locator("code")).to_have_text(CAPPED_SP)
expect(line.locator("span.truncated-note")).to_have_text(MARKER)
# --- the mock's echo: the LLM's context carried the marker AND ---
# --- the grep-pointer notice (the house scripted-turn lens) ------
bubble = _last_brain(page).locator(".bubble")
text = bubble.text_content() or ""
assert TRUNCATION_MARKER in text, text
assert NOTICE in text, text
# The first cap chars reached the model…
assert HEAD_SENTINEL in text, text
# …and the past-the-cap tail did NOT (the cut is real, not cosmetic).
assert TAIL_SENTINEL not in text, text
# --------------------------------------------------------------------------
# 2. Save → shared: the stored tools record carries the truncation, and
# the shared page renders the SAME marker (pixel-identical)
# --------------------------------------------------------------------------
def test_truncated_read_save_and_shared_fidelity(
page: Page,
browser: Browser,
app_url: str,
synced_kb: None,
db_ready: None,
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_submit(page, Q_SAVE)
_wait_settled(page)
# The live marker landed (regression through the save path — the
# tool_result frame stamped the toolAcc entry the save carries).
line = _last_brain(page).locator(".tool-call")
expect(line).to_have_count(1)
expect(line.locator("span.truncated-note")).to_have_text(MARKER)
cookies = _admin_cookies(page)
title = _auto_title(Q_SAVE)
row = _wait_saved_row(app_url, cookies, title)
chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
# The saved message's ``tools`` record carries the truncation —
# ``truncated: true`` + the pinned counts (the saved-chats API;
# the model_dump round-trip fills the non-truncated defaults,
# so the full five-key shape is asserted).
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"]
assert brain_msgs[0]["tools"] == [
{
"name": "read",
"argument": CAPPED_SP,
"truncated": True,
"chars_shown": READ_CAP,
"chars_total": CAP_DOC_TOTAL,
}
], brain_msgs[0]["tools"]
# Share the auto-saved row (phase 55: settled = already saved —
# the Share click shares the linked row) and read back the link.
_grant_clipboard(page, app_url)
_click_share_and_assert_status(page, app_url)
row = _find_row(_chats(app_url, cookies), title)
assert row is not None and row.get("share_url"), row
share_url: str = row["share_url"]
assert SHARE_URL_RE.fullmatch(share_url)
# A FRESH context (no cookies — the guest's only credential is
# the token in the URL): the shared page renders the SAME
# marker on the Reading line (shared.js renders it from the
# stored record — the phase-50 restore contract).
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-title")).to_have_text(title)
anon_line = anon.locator(".msg.brain .tool-call")
expect(anon_line).to_have_count(1)
expect(anon_line).to_contain_text(f"Reading {CAPPED_SP}")
expect(anon_line.locator("code")).to_have_text(CAPPED_SP)
expect(anon_line.locator("span.truncated-note")).to_have_text(MARKER)
# The shared answer still renders the truncation the LLM was
# told about (same stored text, same renderer).
anon_text = anon.locator(".msg.brain .bubble").first.text_content() or ""
assert TRUNCATION_MARKER in anon_text, anon_text
assert NOTICE in anon_text, anon_text
assert HEAD_SENTINEL in anon_text, anon_text
assert TAIL_SENTINEL not in anon_text, anon_text
anon_ctx.close()
anon_ctx = None
finally:
if anon_ctx is not None:
anon_ctx.close()
_delete_chat(app_url, cookies, chat_id)
# --------------------------------------------------------------------------
# 3. The control: the under-cap read executes, streams NO tool_result
# frame, shows NO marker, and saves the plain tools record
# --------------------------------------------------------------------------
def test_short_read_control_no_frame_no_marker(
page: Page, app_url: str, synced_kb: None, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_CONTROL)
_wait_settled(page)
# The read EXECUTED (the tool frame landed)…
frames = _frames(page)
assert _tool_frames(frames) == [
{"type": "tool", "name": "read", "argument": SHORT_SP}
], _tool_frames(frames)
# …but a non-truncated read streams NO tool_result frame (one frame
# = one noteworthy event — the phase-95 additive contract).
assert _result_frames(frames) == [], _result_frames(frames)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False, done
assert any(
s["path"] == SHORT_REL and s["source"] == SOURCE for s in done["sources"]
), done["sources"]
# No marker on the live Reading line.
line = _last_brain(page).locator(".tool-call")
expect(line).to_have_count(1)
expect(line).to_contain_text(f"Reading {SHORT_SP}")
expect(line.locator("code")).to_have_text(SHORT_SP)
expect(line.locator("span.truncated-note")).to_have_count(0)
# The mock's echo: the PLAIN read shape — the whole short document
# (it is under the cap), no marker, no notice.
bubble = _last_brain(page).locator(".bubble")
text = bubble.text_content() or ""
assert SHORT_SENTINEL in text, text
assert TRUNCATION_MARKER not in text, text
assert "TRUNCATED —" not in text, text
# The saved ``tools`` record is the plain pre-truncation shape
# (``truncated`` default False, the counts null — the
# pre-phase-95 shape validates and renders unchanged).
cookies = _admin_cookies(page)
title = _auto_title(Q_CONTROL)
row = _wait_saved_row(app_url, cookies, title)
try:
detail = _get_chat(app_url, cookies, row["id"])
brain_msgs = [m for m in detail["messages"] if m["who"] == "brain"]
assert len(brain_msgs) == 1, detail["messages"]
assert brain_msgs[0]["tools"] == [
{
"name": "read",
"argument": SHORT_SP,
"truncated": False,
"chars_shown": None,
"chars_total": None,
}
], brain_msgs[0]["tools"]
finally:
_delete_chat(app_url, cookies, row["id"])
+9 -3
View File
@@ -42,7 +42,13 @@ from app.config import Settings
from app.models import Document, FolderSummary, GitSource from app.models import Document, FolderSummary, GitSource
from app.rag import agent from app.rag import agent
from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent
from app.rag.llm import LLMClient, RetryPiece, StreamPiece, ToolCallPiece from app.rag.llm import (
LLMClient,
RetryPiece,
StreamPiece,
ToolCallPiece,
ToolResultPiece,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter from app.rag.scaffolding import ScaffoldingFilter
@@ -254,8 +260,8 @@ def _run_call(
async def _consume( async def _consume(
llm: LLMClient, db: Session, holder: AgentHolder llm: LLMClient, db: Session, holder: AgentHolder
) -> list[StreamPiece | ToolCallPiece | RetryPiece]: ) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
out: list[StreamPiece | ToolCallPiece | RetryPiece] = [] out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
async for piece in run_agent( async for piece in run_agent(
llm, llm,
db, db,
+353 -3
View File
@@ -16,6 +16,7 @@ import json
import logging import logging
import math import math
import re import re
import uuid
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@@ -24,15 +25,18 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import delete, func, select, text from sqlalchemy import delete, func, select, text
from sqlalchemy.orm import Session
from app.api import chat as chat_api from app.api import chat as chat_api
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.main import app as fastapi_app from app.main import app as fastapi_app
from app.models import Chunk, GitSource, QueryLog from app.models import Chunk, Document, GitSource, QueryLog
from app.rag import agent from app.rag import agent
from app.rag.agent import AGENT_TOOLS from app.rag.agent import AGENT_TOOLS, READ_TRUNCATION_NOTICE
from app.rag.importer import import_sources from app.rag.importer import import_sources
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece, ToolResultPiece
from app.rag.prompts import build_high_prompt
from app.rag.retriever import TRUNCATION_MARKER
from app.schemas import ChatDoneEvent, SourceRef from app.schemas import ChatDoneEvent, SourceRef
from tests.conftest import ADMIN_PASSWORD from tests.conftest import ADMIN_PASSWORD
@@ -610,6 +614,352 @@ def test_chat_query_log_failure_still_sends_done(client, db, seeded_kb: FakeRagL
# ---------- phase 37: agent document tools on grounded turns ---------- # ---------- phase 37: agent document tools on grounded turns ----------
async def _collect_run_agent(
llm: FakeRagLLM,
db: Session,
system_prompt: str,
settings: Settings,
seed_docs: list[Document],
) -> tuple[list[Any], agent.AgentHolder]:
"""Consume one ``run_agent`` turn, returning the yielded pieces (in
order) and the holder. Phase 95 (task 01): the direct agent-loop
drive — the agent-loop yield order on the real prompt path, the
complement of the endpoint-level ``tool_result`` SSE tests below
(task 02)."""
holder = agent.AgentHolder()
pieces: list[Any] = []
async for piece in agent.run_agent(
llm, # pyright: ignore[reportArgumentType] # duck-typed LLMClient
db,
system_prompt=system_prompt,
user_message=QUESTION,
seed_docs=seed_docs,
settings=settings,
holder=holder,
):
pieces.append(piece)
return pieces, holder
def test_read_cap_truncates_and_yields_tool_result_on_real_prompt_path(
db,
) -> None:
"""Phase 95 (task 01): on the REAL prompt path (a real Postgres
document + the real ``build_high_prompt``), a ``read`` of a document
LONGER than ``settings.read_max_chars`` truncates the result the model
sees — first ``cap`` chars + the shared :data:`TRUNCATION_MARKER` + the
pinned grep-pointer notice — and ``run_agent`` yields exactly ONE
``ToolResultPiece``: AFTER the read's ``tool`` frame (the matching
``ToolCallPiece``) and BEFORE the next model round. The endpoint-level
``tool_result`` SSE frame is asserted separately below (task 02); this
pins the agent-loop yield order on the real prompt path."""
cap = 100
content = "K" * (cap + 40) # 40 chars over the cap
doc = Document(
id=uuid.uuid4(),
source="docs",
path="big.md",
full_path="/tmp/big.md",
title="Big Doc",
content=content,
content_hash="1" * 64,
)
db.add(doc)
db.commit()
try:
# The real prompt path: the actual HIGH prompt for the one doc.
system_prompt = build_high_prompt([doc])
settings = Settings(_env_file=None, read_max_chars=cap) # pyright: ignore[reportCallIssue]
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1", name="read", arguments={"path": "docs/big.md"}
)
]
# the answer request (tools still offered, script
# exhausted) falls back to the thinking + answer stream
]
)
pieces, holder = asyncio.run(
_collect_run_agent(scripted, db, system_prompt, settings, seed_docs=[])
)
# The model's context carried the truncated read — the first cap
# chars, then the shared marker + the pinned grep-pointer notice
# (so a downstream grep is the model's path to the rest). The
# fake records the (mutated-in-place) messages list, so the same
# tool message is aliased across requests — they all carry the
# same content; take the last.
tool_msgs = [
m for r in scripted.seen_messages for m in r if m.get("role") == "tool"
]
assert tool_msgs, "the executed read must be appended as a tool message"
body = tool_msgs[-1]["content"]
assert body.startswith("Document docs/big.md:\n" + content[:cap])
assert TRUNCATION_MARKER in body
assert (
READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) in body
)
# The yield order: the read's ToolCallPiece, then the ONE
# ToolResultPiece, then the next round's answer content.
kinds: list[str] = []
for p in pieces:
if isinstance(p, ToolCallPiece):
kinds.append("toolcall")
elif isinstance(p, ToolResultPiece):
kinds.append("toolresult")
elif isinstance(p, StreamPiece):
kinds.append(p.kind)
assert kinds.count("toolresult") == 1
assert kinds.index("toolcall") < kinds.index("toolresult")
assert kinds.index("toolresult") < kinds.index("content")
# The piece carries (argument, shown, total) — the raw
# source/path the model passed (what the tool frame carries), the
# cap kept, the true length.
(result_piece,) = [p for p in pieces if isinstance(p, ToolResultPiece)]
assert result_piece.name == "read"
assert result_piece.argument == "docs/big.md"
assert result_piece.truncated is True
assert result_piece.chars_shown == cap
assert result_piece.chars_total == len(content)
# Holder accounting: a truncated read is still a SUCCESSFUL call
# (counted + added to context); the tuple is the signal only.
assert holder.tool_calls == 1
assert holder.read_docs == [doc]
assert holder.read_truncations == [("docs/big.md", cap, len(content))]
finally:
db.delete(doc)
db.commit()
def test_read_at_or_under_cap_yields_no_tool_result_on_real_prompt_path(
db,
) -> None:
"""Phase 95 (task 01): the complement — a ``read`` of a document at or
under the cap on the real prompt path is byte-identical to the
pre-phase-95 agent loop: NO ``ToolResultPiece``, no holder entry, no
marker in the model's context."""
cap = 100
content = "K" * cap # exactly at the cap → fits, not truncated
doc = Document(
id=uuid.uuid4(),
source="docs",
path="fits.md",
full_path="/tmp/fits.md",
title="Fits Doc",
content=content,
content_hash="2" * 64,
)
db.add(doc)
db.commit()
try:
system_prompt = build_high_prompt([doc])
settings = Settings(_env_file=None, read_max_chars=cap) # pyright: ignore[reportCallIssue]
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1", name="read", arguments={"path": "docs/fits.md"}
)
]
]
)
pieces, holder = asyncio.run(
_collect_run_agent(scripted, db, system_prompt, settings, seed_docs=[])
)
# No ToolResultPiece, no holder entry.
assert not any(isinstance(p, ToolResultPiece) for p in pieces)
assert holder.read_truncations == []
# The model's context is the whole document, byte-identical to
# the pre-phase-95 read result (no marker, no notice). (The fake
# aliases the mutated messages list, so take the last tool msg.)
tool_msgs = [
m for r in scripted.seen_messages for m in r if m.get("role") == "tool"
]
assert tool_msgs, "the executed read must be appended as a tool message"
assert tool_msgs[-1]["content"] == "Document docs/fits.md:\n" + content
assert TRUNCATION_MARKER not in tool_msgs[-1]["content"]
# Still a successful read.
assert holder.tool_calls == 1
assert holder.read_docs == [doc]
finally:
db.delete(doc)
db.commit()
def _insert_big_doc(db, content: str) -> Document:
"""One bare ``documents`` row (no chunks — the ``read`` lookup is a
(source, path) identity match, not a retrieval) for the SSE-level
read-cap tests: a document the model can only reach through the
``read`` tool."""
doc = Document(
id=uuid.uuid4(),
source="docs",
path="big-read.md",
full_path="/tmp/big-read.md",
title="Big Read Doc",
content=content,
content_hash="3" * 64,
)
db.add(doc)
db.commit()
return doc
def test_truncated_read_streams_tool_result_frame_after_tool_frame(
client,
db,
seeded_kb: FakeRagLLM,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 95 (task 02, the A15 extension): a grounded turn whose
scripted ``read`` hits a document LONGER than ``read_max_chars``
(the cap lowered via the settings override — the task 01
``Settings(_env_file=None, read_max_chars=…)`` pattern) streams the
``tool`` → ``tool_result`` → ``delta…`` → ``done`` sequence: EXACTLY
ONE ``tool_result`` frame, AFTER the matching ``tool`` frame (the
line is already on screen) and BEFORE the next round's first frame,
with the right shape and counts (``chars_shown`` = the cap,
``chars_total`` = the true length). The model's context carried the
truncated read (marker + pinned grep-pointer notice); the read is
still cited (a truncated read is a successful call)."""
cap = 100
content = "K" * (cap + 150)
doc = _insert_big_doc(db, content)
live = get_settings()
monkeypatch.setattr(
chat_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
relevance_threshold=live.relevance_threshold,
read_max_chars=cap,
),
)
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1", name="read", arguments={"path": "docs/big-read.md"}
)
]
# the answer request still carries the tools (1 round < the
# default cap of 10); the script is exhausted, so the fake
# falls back to the thinking + answer stream
]
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
db.delete(doc)
db.commit()
types = [f["type"] for f in frames]
assert "error" not in types
tool_i = types.index("tool")
tool_result_i = types.index("tool_result")
# Exactly one tool_result frame…
assert types.count("tool_result") == 1
# …AFTER the matching tool frame and BEFORE the next model round's
# first frame (the answer's deltas): tool → tool_result → delta…
assert tool_i + 1 == tool_result_i
assert tool_result_i < min(i for i, t in enumerate(types) if t == "delta")
# The frame's exact shape: the additive seventh event type carries
# the name/argument of the matching tool frame + the counts.
frame = frames[tool_result_i]
assert set(frame) == {
"type",
"name",
"argument",
"truncated",
"chars_shown",
"chars_total",
}
assert frame["name"] == frames[tool_i]["name"] == "read"
assert frame["argument"] == frames[tool_i]["argument"] == "docs/big-read.md"
assert frame["truncated"] is True
assert frame["chars_shown"] == cap # the cap kept
assert frame["chars_total"] == len(content) # the true length
# The LLM's context carried the honest truncation: first cap chars +
# the shared marker + the pinned grep-pointer notice (the fake
# aliases the mutated messages list — take the last tool msg).
tool_msgs = [
m for r in scripted.seen_messages for m in r if m.get("role") == "tool"
]
assert tool_msgs
body = tool_msgs[-1]["content"]
assert body.startswith(f"Document docs/big-read.md:\n{content[:cap]}")
assert TRUNCATION_MARKER in body
assert READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content)) in body
# The truncated read is still a SUCCESSFUL call — cited in done.
done = frames[-1]
assert done["type"] == "done" and done["deflected"] is False
assert ("docs", "big-read.md") in [(s["source"], s["path"]) for s in done["sources"]]
assert ("docs", "homelab/kubernetes.md") in [
(s["source"], s["path"]) for s in done["sources"]
]
def test_untruncated_read_streams_no_tool_result_frame(
client,
db,
seeded_kb: FakeRagLLM,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 95 (task 02): the complement at the SSE level — a ``read``
of a document AT OR UNDER the cap (the same long document, cap
raised past its true length) streams NO ``tool_result`` frame (one
frame = one noteworthy event; the six pre-existing event types are
byte-identical), the ``tool`` frame is unchanged, and the model's
context is the WHOLE document (no marker, no notice)."""
content = "K" * 250
doc = _insert_big_doc(db, content)
live = get_settings()
monkeypatch.setattr(
chat_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
relevance_threshold=live.relevance_threshold,
read_max_chars=10_000, # far over the doc's true length
),
)
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1", name="read", arguments={"path": "docs/big-read.md"}
)
]
]
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
db.delete(doc)
db.commit()
types = [f["type"] for f in frames]
assert "error" not in types
assert types.count("tool_result") == 0 # one frame = one noteworthy event
assert types.count("tool") == 1
(tool_frame,) = [f for f in frames if f["type"] == "tool"]
assert set(tool_frame) == {"type", "name", "argument"} # byte-identical shape
assert tool_frame["argument"] == "docs/big-read.md"
assert frames[-1]["type"] == "done" and frames[-1]["deflected"] is False
# The model saw the WHOLE document — no marker, no notice.
tool_msgs = [
m for r in scripted.seen_messages for m in r if m.get("role") == "tool"
]
assert tool_msgs
assert tool_msgs[-1]["content"] == "Document docs/big-read.md:\n" + content
assert TRUNCATION_MARKER not in tool_msgs[-1]["content"]
def test_grounded_turn_streams_tool_frames_and_cites_read_doc( def test_grounded_turn_streams_tool_frames_and_cites_read_doc(
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None: ) -> None:
+24 -3
View File
@@ -54,6 +54,9 @@ SHARED_OUT_KEYS = {"title", "messages"}
#: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional #: A full ``bor.chat.v1`` brain record (phase 14 shape) — every optional
#: key present; the round-trip test asserts it survives byte-identical. #: key present; the round-trip test asserts it survives byte-identical.
#: 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).
FULL_BRAIN: dict[str, Any] = { FULL_BRAIN: dict[str, Any] = {
"who": "brain", "who": "brain",
"text": "Your k3s cluster runs on three nodes — you've got this.", "text": "Your k3s cluster runs on three nodes — you've got this.",
@@ -64,12 +67,30 @@ FULL_BRAIN: dict[str, Any] = {
"suggestions": ["What ports does Traefik expose?"], "suggestions": ["What ports does Traefik expose?"],
"thinking": "The kubernetes doc covers the cluster layout…", "thinking": "The kubernetes doc covers the cluster layout…",
"tools": [ "tools": [
{"name": "read", "argument": "Homelab/kubernetes.md"}, {
{"name": "ls", "argument": None}, "name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
},
{
"name": "ls",
"argument": None,
"truncated": False,
"chars_shown": None,
"chars_total": None,
},
# Saved chats persisting the pre-phase-70 tool names still # Saved chats persisting the pre-phase-70 tool names still
# validate — ``name`` is opaque to the API (no migration, # validate — ``name`` is opaque to the API (no migration,
# locked: old chats render fine). # locked: old chats render fine).
{"name": "read_document", "argument": "Homelab/legacy-notes.md"}, {
"name": "read_document",
"argument": "Homelab/legacy-notes.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
},
], ],
"stopped": False, "stopped": False,
} }
+205 -12
View File
@@ -47,12 +47,21 @@ from app.models import Document, GitSource
from app.rag import agent from app.rag import agent
from app.rag.agent import ( from app.rag.agent import (
AGENT_TOOLS, AGENT_TOOLS,
READ_TRUNCATION_NOTICE,
AgentHolder, AgentHolder,
MalformedReplyError, MalformedReplyError,
run_agent, run_agent,
) )
from app.rag.llm import LLMClient, LLMError, RetryPiece, StreamPiece, ToolCallPiece from app.rag.llm import (
LLMClient,
LLMError,
RetryPiece,
StreamPiece,
ToolCallPiece,
ToolResultPiece,
)
from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt from app.rag.prompts import TOOLS_SECTION, _base, build_deflect_prompt, build_high_prompt
from app.rag.retriever import TRUNCATION_MARKER
if TYPE_CHECKING: if TYPE_CHECKING:
from app.rag.scaffolding import ScaffoldingFilter from app.rag.scaffolding import ScaffoldingFilter
@@ -121,11 +130,12 @@ async def _run(
settings: Settings, settings: Settings,
seed_docs: list[Document] | None = None, seed_docs: list[Document] | None = None,
history: Sequence[dict[str, Any]] = (), history: Sequence[dict[str, Any]] = (),
) -> list[StreamPiece | ToolCallPiece | RetryPiece]: ) -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
"""Consume one ``run_agent`` turn; *history* (phase 74) is the """Consume one ``run_agent`` turn; *history* (phase 74) is the
client's prior turns spliced between system and user (default client's prior turns spliced between system and user (default
``()`` — the pre-phase-74 two-message request).""" ``()`` — the pre-phase-74 two-message request). Phase 95: the loop
out: list[StreamPiece | ToolCallPiece | RetryPiece] = [] may also yield a ``ToolResultPiece`` (a truncated ``read``)."""
out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
async for piece in run_agent( async for piece in run_agent(
cast("LLMClient", llm), cast("LLMClient", llm),
cast("Session", None), cast("Session", None),
@@ -192,15 +202,25 @@ def test_agent_tools_names_and_parameters() -> None:
# section already carries (every refusal of a 12-call run was # section already carries (every refusal of a 12-call run was
# ALREADY_IN_CONTEXT); the rule now leads the description instead # ALREADY_IN_CONTEXT); the rule now leads the description instead
# of sitting mid-paragraph, and the tool is framed as "only for # of sitting mid-paragraph, and the tool is framed as "only for
# documents NOT already in <documents>". # documents NOT already in <documents>". Phase 95 (task 01): the
# read-truncation sentence is inserted before the one-call-at-a-
# time discipline clause (the discipline rule stays last, as in the
# other two tools) — a capped read carries the TRUNCATED notice and
# the `grep` follow-up (the pinned copy).
assert read["description"] == ( assert read["description"] == (
"Do not call this tool for a document already shown in " "Do not call this tool for a document already shown in "
"the <documents> section, even when the user asks you to " "the <documents> section, even when the user asks you to "
"open or read it — its full text is already in your " "open or read it — its full text is already in your "
"prompt; answer directly from it. Use it only to add a " "prompt; answer directly from it. Use it only to add a "
"document NOT already in <documents> to your context, " "document NOT already in <documents> to your context, "
"by its combined `source/path` string. Call one tool at " "by its combined `source/path` string. Very large "
"a time — wait for this result before your next call." "documents are truncated: you receive the first part "
"plus a TRUNCATED notice naming how many more characters "
"exist — the notice is authoritative, the document did "
"NOT end where it stopped. Follow it and use `grep` "
"(pattern) to locate the rest — it searches the whole "
"document. Call one tool at a time — wait for this "
"result before your next call."
) )
read_params = read["parameters"] read_params = read["parameters"]
assert read_params["type"] == "object" assert read_params["type"] == "object"
@@ -1519,6 +1539,177 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
assert llm.requests[2][1] == AGENT_TOOLS assert llm.requests[2][1] == AGENT_TOOLS
# ---------- phase 95: the read cap (bounded reads, honest truncation) ----------
def test_read_exactly_at_cap_is_byte_identical_and_untruncated(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 95 boundary: a document whose content length EQUALS the cap
fits — read whole, byte-identical to the pre-phase-95 result (no
marker, no notice, no holder entry, no ``ToolResultPiece``)."""
cap = 20
content = "x" * cap
doc = _doc("S", "big.md", "Big", content)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
[StreamPiece("content", "ans")],
)
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
# Byte-identical to today's read result (no marker, no notice).
assert llm.requests[1][0][3]["content"] == "Document S/big.md:\n" + content
assert TRUNCATION_MARKER not in llm.requests[1][0][3]["content"]
# No truncation recorded, none surfaced to the loop.
assert holder.read_truncations == []
assert not any(isinstance(p, ToolResultPiece) for p in out)
# Still a successful read.
assert holder.read_docs == [doc]
assert holder.tool_calls == 1
def test_read_at_cap_plus_one_truncates_with_marker_and_notice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 95 boundary: ONE char over the cap truncates — the first
``cap`` chars + the shared :data:`TRUNCATION_MARKER` + the pinned
grep-pointer notice (``{total}`` = the true length, ``{shown}`` = the
cap), and the truncation is recorded on the holder. A truncated read
is still a successful call (``tool_calls`` / ``read_docs`` as today)."""
cap = 20
content = "x" * (cap + 1)
doc = _doc("S", "big.md", "Big", content)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
expected = (
"Document S/big.md:\n"
+ content[:cap]
+ "\n"
+ TRUNCATION_MARKER
+ "\n"
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=cap + 1)
)
assert llm.requests[1][0][3]["content"] == expected
# (argument, chars_shown, chars_total) — the raw argument the tool
# frame carries, the cap kept, the true length.
assert holder.read_truncations == [("S/big.md", cap, cap + 1)]
assert holder.read_docs == [doc] # still added to the context
assert holder.tool_calls == 1 # still a counted, successful call
def test_read_truncation_notice_is_pinned() -> None:
"""Phase 95: the notice copy is pinned — it names the true length
(``{total}``), the cap kept (``{shown}``), states the rest is NOT
shown (the document did not end where it stopped), and points at
``grep`` (which searches the whole document)."""
assert READ_TRUNCATION_NOTICE.format(shown=100, total=250) == (
"TRUNCATED — this document is 250 characters; only the first "
"100 are in your context. The rest is NOT shown. Use grep "
"(pattern) to locate what you need — grep searches the whole "
"document."
)
def test_run_agent_yields_tool_result_after_tool_frame_before_next_round(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 95: ``run_agent`` yields exactly ONE ``ToolResultPiece`` per
truncated read — AFTER the round's ``tool`` frame (the matching
``ToolCallPiece``) and BEFORE the next model round (the answer
pieces). It carries (argument, shown, total); ``argument`` is the
same value the matching ``tool`` frame carries (the raw
``source/path`` the model passed)."""
cap = 20
content = "y" * (cap + 5)
doc = _doc("S", "big.md", "Big", content)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
[StreamPiece("content", "ans")],
)
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
# The piece order: the read's ToolCallPiece, then the ToolResultPiece,
# then the next round's content (the answer).
assert isinstance(out[0], ToolCallPiece) and out[0].name == "read"
piece = out[1]
assert isinstance(piece, ToolResultPiece)
assert isinstance(out[2], StreamPiece)
assert piece.name == "read"
assert piece.argument == "S/big.md"
assert piece.truncated is True
assert piece.chars_shown == cap
assert piece.chars_total == cap + 5
# Exactly one ToolResultPiece for the one truncated read.
assert [p for p in out if isinstance(p, ToolResultPiece)] == [piece]
def test_run_agent_short_read_yields_no_tool_result_piece(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 95: a read at or under the cap yields NO ``ToolResultPiece``
— the non-truncated stream is byte-identical to the pre-phase-95
one (just the ``ToolCallPiece`` + the answer)."""
cap = 20
content = "z" * cap # exactly at the cap → not truncated
doc = _doc("S", "small.md", "Small", content)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/small.md"})],
[StreamPiece("content", "ans")],
)
out = asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
assert not any(isinstance(p, ToolResultPiece) for p in out)
assert holder.read_truncations == []
# Order: the ToolCallPiece then the answer content (no piece between).
assert isinstance(out[0], ToolCallPiece) and out[0].name == "read"
assert isinstance(out[1], StreamPiece)
def test_read_truncation_does_not_touch_refusal_paths(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 95: the read refusal paths are untouched by the cap — a SEED
document that is over the cap is still refused with
``ALREADY_IN_CONTEXT`` (not truncated, nothing recorded, nothing
counted), and an unknown path is still the no-document refusal (no
content is read, so no truncation either)."""
big = "B" * 5000 # far over the tiny cap below
seed = _doc("S", "seed.md", "Seed", big)
monkeypatch.setattr(agent, "find_document", lambda db, source, path: None)
monkeypatch.setattr(agent, "all_documents", lambda db: [])
# (a) Reading the (over-cap) seed doc → ALREADY_IN_CONTEXT (refusal).
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/seed.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings(read_max_chars=100), seed_docs=[seed]))
assert llm.requests[1][0][3]["content"] == agent.ALREADY_IN_CONTEXT
assert holder.read_truncations == []
assert holder.tool_calls == 0 and holder.read_docs == []
# (b) An unknown path → the no-document refusal (argument echoed),
# even though a big doc could have truncated — no content is read.
holder2 = AgentHolder()
llm2 = ScriptedLLM(
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/missing.md"})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm2, holder2, _settings(read_max_chars=100), seed_docs=[seed]))
assert llm2.requests[1][0][3]["content"] == (
"No document at 'S/missing.md' — check the ls output."
)
assert holder2.read_truncations == []
assert holder2.tool_calls == 0 and holder2.read_docs == []
def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None: def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
holder = AgentHolder() holder = AgentHolder()
llm = ScriptedLLM( llm = ScriptedLLM(
@@ -2322,8 +2513,8 @@ def test_round_failure_after_first_piece_is_terminal(monkeypatch: pytest.MonkeyP
) )
sleeps = _record_sleeps(monkeypatch) sleeps = _record_sleeps(monkeypatch)
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]: async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
out: list[StreamPiece | ToolCallPiece | RetryPiece] = [] out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
with pytest.raises(LLMError, match="mid-stream drop"): with pytest.raises(LLMError, match="mid-stream drop"):
async for piece in run_agent( async for piece in run_agent(
cast("LLMClient", llm), cast("LLMClient", llm),
@@ -2384,8 +2575,8 @@ def test_zero_retries_is_one_plain_attempt(monkeypatch: pytest.MonkeyPatch) -> N
llm = FailingLLM([([], LLMError("connection refused"))]) llm = FailingLLM([([], LLMError("connection refused"))])
sleeps = _record_sleeps(monkeypatch) sleeps = _record_sleeps(monkeypatch)
async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece]: async def drain() -> list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece]:
out: list[StreamPiece | ToolCallPiece | RetryPiece] = [] out: list[StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece] = []
with pytest.raises(LLMError, match="connection refused"): with pytest.raises(LLMError, match="connection refused"):
async for piece in run_agent( async for piece in run_agent(
cast("LLMClient", llm), cast("LLMClient", llm),
@@ -2434,7 +2625,9 @@ def test_abandon_mid_retry_sleep_leaks_nothing(monkeypatch: pytest.MonkeyPatch)
holder=holder, holder=holder,
) )
async def consumer() -> list[StreamPiece | ToolCallPiece | RetryPiece]: async def consumer() -> list[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
]:
return [p async for p in gen] return [p async for p in gen]
task = asyncio.ensure_future(consumer()) task = asyncio.ensure_future(consumer())
+26
View File
@@ -209,6 +209,32 @@ def test_agent_max_rounds_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> N
_settings() _settings()
def test_read_max_chars_default_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 95: the agent ``read`` tool's result is capped at
``BOR_READ_MAX_CHARS`` (default 128 000 chars ≈ 32k tokens — a
quarter of the owner's 128k-token minimum context). Env-tunable in
both directions."""
monkeypatch.delenv("BOR_READ_MAX_CHARS", raising=False)
assert _settings().read_max_chars == 128_000
monkeypatch.setenv("BOR_READ_MAX_CHARS", "5000")
assert _settings().read_max_chars == 5000
# ``0`` is legal (every non-empty read truncates to the marker +
# notice) — it is not a kill switch, so no lower-bound error.
monkeypatch.setenv("BOR_READ_MAX_CHARS", "0")
assert _settings().read_max_chars == 0
def test_read_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> None:
"""A negative cap is a typo — it would slice from the END of the
content (negative indexing) instead of failing, so the validator
fails loudly at startup (the ``agent_max_rounds`` pattern)."""
monkeypatch.setenv("BOR_READ_MAX_CHARS", "-1")
with pytest.raises(ValidationError, match="read_max_chars"):
_settings()
def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None: def test_stream_thinking_default_true_and_env_parse(monkeypatch: pytest.MonkeyPatch) -> None:
"""Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default, """Phase 17 kill-switch (``BOR_STREAM_THINKING``): on by default,
``0``/``false`` turn the ``thinking`` SSE frames off.""" ``0``/``false`` turn the ``thinking`` SSE frames off."""
+18
View File
@@ -229,6 +229,24 @@ def test_tools_section_phase72_contract_clauses() -> None:
assert "not a directory or file path" not in TOOLS_SECTION assert "not a directory or file path" not in TOOLS_SECTION
def test_tools_section_phase95_read_truncation_clause() -> None:
"""Phase 95 (task 01): the ``read`` teaching gains exactly one line —
very large documents are capped, a cut read returns the first part
plus the TRUNCATED notice (the document did not end where it
stopped), and ``grep`` is the follow-up (it searches the whole
document). The ``ls``/``grep`` teaching is untouched (phase 94 owns
``ls``) — the clause is pinned byte-for-byte in the constant."""
assert (
"Very large documents are capped: a cut read returns the first "
"part plus a TRUNCATED notice — the document did not end where "
"it stopped; use `grep` (pattern) to find the rest, it searches "
"the whole document."
) in TOOLS_SECTION
# It rides the HIGH prompt and never the LOW (deflected) prompt.
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes")
assert "Very large documents are capped" in build_high_prompt([doc])
def test_tools_section_phase72_clauses_in_high_prompt_not_low() -> None: def test_tools_section_phase72_clauses_in_high_prompt_not_low() -> None:
"""Phase 72/94: the contract clauses ride the HIGH prompt with the """Phase 72/94: the contract clauses ride the HIGH prompt with the
rest of the section and never leak into the LOW/deflection prompt rest of the section and never leak into the LOW/deflection prompt
+222
View File
@@ -0,0 +1,222 @@
"""Unit: the phase-95 (task 02) truncation-marker frontend contract.
No new Python app logic exists for the marker itself — the behavior
lives in ``frontend/assets/app.js`` (the ``tool_result`` SSE branch +
the ``toolAcc`` stamp + the phase-14 restore marker),
``frontend/assets/shared.js`` (the shared page's local tool-line
render) and ``frontend/assets/styles.css`` (the theme-neutral
``.truncated-note`` rule). Like the other frontend-adjacent unit files
(``test_frontend_tool_states.py`` is the phase-37 precedent), this
module pins the JS/CSS markers the story depends on, so a silent
regression in the handler, the pinned marker copy, the persistence
stamp, or the styling is caught without a browser. The E2E gate is the
phase's story suite (task 03).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
SHARED_JS = FRONTEND / "assets" / "shared.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
#: The pinned marker copy (the unit + E2E assertion target): plain
#: integers, no thousands separators, the em-dash per the owner's TODO.
MARKER_TEMPLATE = '" (truncated — showing "'
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _shared_js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def test_tool_result_branch_is_a_first_class_turn_branch() -> None:
"""The turn handler must branch on `tool_result` frames (the
seventh, optional event type — the A15 extension): the branch sits
after `done` (a frame for a settled turn is a tolerated late
append, never a crash) and before `error`, settles the tool-line
clock like every other frame, and must never flip the state
machine (the phase-37/48 lifecycle is untouched)."""
js = _js()
done_idx = js.find('ev.type === "done"')
tool_result_idx = js.find('ev.type === "tool_result"')
error_idx = js.find('ev.type === "error"')
assert -1 < done_idx < tool_result_idx < error_idx, (
"the turn handler must branch on tool_result frames (after done,"
" before error)"
)
branch = js[tool_result_idx:error_idx]
assert "settleToolLine()" in branch, (
"a frame arrived — the latest tool line's elapsed clock settles"
)
assert "appendTruncatedNote(wrap, argument, shown, total)" in branch
# No UI-state transition: the state machine never knows the marker.
assert "setUiState" not in branch
assert "aborted" not in branch, (
"the aborted guard lives at the dispatch top, not per branch"
)
def test_truncation_marker_copy_is_pinned() -> None:
"""The marker text is pinned EXACTLY: \" (truncated — showing N of
M chars)\" — plain integers (no separators), a leading space (it
follows the line's <code> child), the em-dash per the TODO copy.
Both app.js's helper and shared.js's local render carry the same
template (pixel-identical marker — the phase-50 restore
contract)."""
js = _js()
fn = js.find("function appendTruncatedNote")
assert fn != -1, "appendTruncatedNote must exist in app.js"
body = js[fn : js.find("\n}\n", fn)]
assert MARKER_TEMPLATE in body
assert '+ charsShown + " of " + charsTotal + " chars)"' in body
shared = _shared_js()
fn = shared.find("function addToolLines")
assert fn != -1
sbody = shared[fn : shared.find("\n}\n", fn)]
assert MARKER_TEMPLATE in sbody
assert 'chars_shown) || 0) + " of " + (Number(t.chars_total) || 0) + " chars)"' in sbody
def test_append_truncated_note_matches_newest_line_and_uses_text_content() -> None:
"""appendTruncatedNote: the target is the NEWEST `.tool-call` line
whose `<code>` child carries the frame's argument (the raw
source/path — the same string the matching `tool` frame put in the
line), scanned newest-first; the marker is a SPAN sibling appended
to the existing line (createElement + className + textContent only
— the house "this file never builds HTML" rule, no innerHTML); a
frame whose line is gone (New Chat mid-turn) is a silent no-op."""
js = _js()
fn = js.find("function appendTruncatedNote")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert 'wrap?.querySelector?.(".tool-calls")' in body, (
"a wrap without tool lines is a silent no-op"
)
assert 'querySelectorAll(".tool-call")' in body
assert "for (let i = lines.length - 1; i >= 0; i -= 1)" in body, (
"newest line first — the last call for that argument"
)
assert 'code.textContent !== argument' in body, (
"the match key is the code child's argument (the raw source/path)"
)
assert 'note.className = "truncated-note"' in body
assert 'lines[i].appendChild(note)' in body, (
"DOM append to the EXISTING line — no new line, no re-render"
)
assert "innerHTML" not in body, (
"no HTML injection surface — createElement + textContent only"
)
# The no-op guard: a wrap without tool lines returns silently; a scan
# that finds no matching line falls off the loop without appending.
assert "if (!calls) return;" in body
def test_tool_result_branch_stamps_the_newest_toolacc_entry() -> None:
"""The `tool_result` frame stamps the matching toolAcc entry (same
argument, NEWEST — the reverse scan mirrors the line match) with
`truncated` + `chars_shown` + `chars_total` — the `done` save point
below then carries it with zero other change (the persistence
shape rides the existing `tools` key). The stamp is gated on the
frame's argument + truncated truth (a malformed frame is a silent
no-op for persistence)."""
js = _js()
tool_result_idx = js.find('ev.type === "tool_result"')
error_idx = js.find('ev.type === "error"')
assert -1 < tool_result_idx < error_idx
branch = js[tool_result_idx:error_idx]
assert "t.truncated = true" in branch
assert "t.chars_shown = shown" in branch
assert "t.chars_total = total" in branch
assert "for (let i = toolAcc.length - 1; i >= 0; i -= 1)" in branch, (
"the NEWEST matching entry (the reverse scan, same rule as the"
" line match) gets stamped — then break"
)
assert "t.argument === argument" in branch
assert "break" in branch
# The guard: the stamp only runs for a real truncation with an argument.
assert "if (argument && ev.truncated)" in branch
# The saved payload rides the existing save point — no second tools
# key, no new record field.
done_block = js[js.find('ev.type === "done"') : tool_result_idx]
assert "tools: toolAcc.length ? toolAcc : undefined" in done_block
def test_restore_path_renders_the_stored_marker() -> None:
"""The phase-14 LOCAL restore path (renderStoredMessage): a stored
tool record with `truncated` + the counts re-renders the SAME
marker next to its Reading line, right after the line is restored
(the same order as the live frames). A pre-phase-95 record (no
field — `t.truncated` falsy) renders unchanged (no marker, no
migration)."""
js = _js()
fn = js.find("function renderStoredMessage")
assert fn != -1
end = js.find("function restoreConversation")
body = js[fn:end]
assert "appendToolLine(wrap, t.name, arg)" in body
assert "t.truncated && arg" in body, (
"only an argument-bearing (Reading) record with the flag renders"
" the marker"
)
assert (
"appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0,"
" 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)")
note_idx = body.find("appendTruncatedNote(wrap, arg,")
assert -1 < line_idx < note_idx
def test_shared_page_renders_the_stored_marker() -> None:
"""shared.js's local tool-line render (addToolLines): the same
marker from the stored record — a span sibling appended to the
line, after the line's existing children (the template text + the
<code> argument), textContent only (no HTML from storage, ever). A
record saved before phase 95 renders exactly as before."""
js = _shared_js()
fn = js.find("function addToolLines")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert "t.truncated && argument" in body
assert 'note.className = "truncated-note"' in body
assert 'line.appendChild(note)' in body
assert MARKER_TEMPLATE in body
# Still textContent-only: the marker adds no innerHTML surface, and
# the three argument-bearing lines keep their textContent treatment.
assert body.count("code.textContent = argument") == 3
assert "innerHTML" not in body
def test_truncated_note_style_is_theme_neutral() -> None:
"""styles.css: `.tool-call .truncated-note` exists and colors ONLY
through a `var(--…)` token (the phase-92 zero-literal invariant —
no new hue; under phase 93's monochrome theme it grays
automatically, and the marker stays TEXT, never color alone, B5).
--ink-soft is the AA-safe soft-ink the status suffixes already
borrow."""
css = _css()
m = re.search(r"\.tool-call \.truncated-note \{([^}]*)\}", css)
assert m, "the .tool-call .truncated-note rule must exist"
rule = m.group(1)
assert "var(--ink-soft)" in rule
assert "color: var(--ink-soft)" in rule
# Theme-neutral: the whole rule is a single var() color — no hex,
# no rgb(), no other property.
assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgb\(", rule)
def test_no_cdn_added() -> None:
"""AGENTS.md rule 6: the marker 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
+165 -3
View File
@@ -41,7 +41,15 @@ def _source_ref() -> dict:
def _tool_call() -> dict: def _tool_call() -> dict:
return {"name": "read", "argument": "Homelab/kubernetes.md"} # Phase 95 (task 02): the truncation record rides the same entry —
# the CURRENT full shape (additive fields, defaults for a plain read).
return {
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
}
def _user_message(text: str = "How did I install k3s?") -> ChatMessage: def _user_message(text: str = "How did I install k3s?") -> ChatMessage:
@@ -341,8 +349,22 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"suggestions": None, "suggestions": None,
"thinking": "The kubernetes doc covers the cluster layout…", "thinking": "The kubernetes doc covers the cluster layout…",
"tools": [ "tools": [
{"name": "read", "argument": "Homelab/kubernetes.md"}, # Phase 95 (task 02): the current full shape — one
{"name": "ls", "argument": None}, # truncated read (the marker record) + a plain ls.
{
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
},
{
"name": "ls",
"argument": None,
"truncated": False,
"chars_shown": None,
"chars_total": None,
},
], ],
"stopped": None, "stopped": None,
}, },
@@ -393,3 +415,143 @@ def test_realistic_payload_round_trips_through_update_model() -> None:
} }
payload = SavedChatUpdate.model_validate({"messages": [msg]}) payload = SavedChatUpdate.model_validate({"messages": [msg]})
assert payload.model_dump()["messages"] == [msg] assert payload.model_dump()["messages"] == [msg]
# ---------------------------------------------------------------------------
# Phase 95 (task 02): ToolCall truncation fields + ChatToolResultEvent
# ---------------------------------------------------------------------------
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
and round-trips ``model_dump()`` unchanged — the save payload carries
it with zero other change (the UI re-renders the marker from it)."""
raw = {
"name": "read",
"argument": "Homelab/big.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
}
call = ToolCall.model_validate(raw)
assert call.truncated is True
assert call.chars_shown == 128_000
assert call.chars_total == 204_000
assert call.model_dump() == raw
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
UNCHANGED: ``truncated`` defaults to False (the marker is absent),
the counts to None. No migration (``ChatMessage.tools`` is JSON);
only the dump gains the additive keys with their defaults."""
old_shape = {"name": "read", "argument": "Homelab/kubernetes.md"}
call = ToolCall.model_validate(old_shape)
assert call.truncated is False
assert call.chars_shown is None
assert call.chars_total is None
dumped = call.model_dump()
assert dumped["name"] == "read" and dumped["argument"] == "Homelab/kubernetes.md"
assert dumped["truncated"] is False
assert dumped["chars_shown"] is None and dumped["chars_total"] is None
def test_tool_call_old_shape_message_still_round_trips_as_record() -> None:
"""The record-level backward-compat: a pre-phase-95 brain message
(old-shape ``tools``) validates inside ``ChatMessage`` and dumps back
as a VALID record of the same shape (the frontend renders it without
the marker — ``truncated`` falsy)."""
old_message = {
"who": "brain",
"text": "You've got this!",
"sources": None,
"deflected": False,
"suggestions": None,
"thinking": None,
"tools": [
{"name": "read", "argument": "Homelab/kubernetes.md"},
{"name": "read_document", "argument": "Homelab/legacy.md"},
],
"stopped": None,
}
msg = ChatMessage.model_validate(old_message)
assert all(t.truncated is False for t in (msg.tools or []))
# Re-validating the dump is a no-op (lossless record round-trip).
ChatMessage.model_validate(msg.model_dump())
def test_tool_call_counts_reject_negative() -> None:
"""Phase 83 bounds philosophy: the counts are non-negative
(``ge=0``) — a negative count is not a real record."""
with pytest.raises(ValidationError):
ToolCall.model_validate(
{
"name": "read",
"argument": "x",
"truncated": True,
"chars_shown": -1,
"chars_total": 5,
}
)
with pytest.raises(ValidationError):
ToolCall.model_validate(
{
"name": "read",
"argument": "x",
"truncated": True,
"chars_shown": 5,
"chars_total": -1,
}
)
def test_chat_tool_result_event_shape() -> None:
"""The A15 extension's wire shape (phase 95 task 02): the seventh,
OPTIONAL SSE event type — ``{type, name, argument, truncated,
chars_shown, chars_total}`` — with the pinned field order, the
``tool_result`` default, ``truncated`` defaulting True (the emission
trigger), and the non-negative counts."""
from app.schemas import ChatToolResultEvent
ev = ChatToolResultEvent(
name="read",
argument="docs/big.md",
truncated=True,
chars_shown=128_000,
chars_total=204_000,
)
dumped = ev.model_dump()
assert list(dumped) == [
"type",
"name",
"argument",
"truncated",
"chars_shown",
"chars_total",
]
assert dumped == {
"type": "tool_result",
"name": "read",
"argument": "docs/big.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
}
# The emission trigger defaults: the pump always builds the frame from
# a piece, so a frame that ever exists carries the truncation truth.
minimal = ChatToolResultEvent(
name="read", argument=None, chars_shown=0, chars_total=0
).model_dump()
assert minimal["truncated"] is True
def test_chat_tool_result_event_counts_reject_negative() -> None:
"""The frame's counts are non-negative (``ge=0``), like the record's."""
from app.schemas import ChatToolResultEvent
with pytest.raises(ValidationError):
ChatToolResultEvent(name="read", argument="x", chars_shown=-1, chars_total=5)
with pytest.raises(ValidationError):
ChatToolResultEvent(name="read", argument="x", chars_shown=5, chars_total=-1)