phase: 182_verbatim_show_text
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**

- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
This commit is contained in:
2026-10-15 08:33:12 -04:00
parent 814a400e6d
commit b34b8ca672
104 changed files with 10852 additions and 203 deletions
+35 -3
View File
@@ -84,6 +84,25 @@ A15 — `thinking`, `tool`, unlimited tool rounds under
never-truncated contract is unchanged; the `read`-tool cap is
the only exception, owner-permitted 2026-09-10.
Phase 182 (verbatim text display, owner chat 2026-10-14) adds
**`show_text(path, offset?, limit?)` as the fifth agent tool** — offered
only when `BOR_ANSWER_TEXT=1` (the default), gated exactly like
`show_image`, with its own `<tools>` clause and its own kill switch — and
the **additive `done.answer_text`** field above. The new settings are
`BOR_ANSWER_TEXT` (master switch, ON), `BOR_SHOW_TEXT_MAX_CHARS` (per-block
budget, 12 000) and `BOR_SHOW_TEXT_MAX_BLOCKS` (per-answer budget, 5), all
following the `agent_max_rounds` pattern (`0` inert, negative refuses
startup). The principle in one sentence: **the model points, the server
supplies the bytes** — a ` ```bor source/path 12-40 ` fence or a
`show_text` call names a location (canonical locator `source/path` or
`source/path#start-end`, 1-based and inclusive), and the displayed text is
sliced from `documents.content` server-side, so what the user copies is the
knowledge base, never a re-typed approximation. **A1–A6 and A15 are
otherwise
UNCHANGED:** no new SSE event type, no new table or column, no new model,
endpoint or transport, no change to retrieval, the honesty gate, the
persona text or the embedding dimension.
---
## 3. High-Level Architecture
@@ -129,7 +148,9 @@ POST /api/chat {message, history?} (auth: require_user)
→ cosine top-100 ∪ FTS top-30 → RRF fuse (k=60) [A7]
├─ HIGH (cosine ≥ 0.62 OR fts_hits > 0):
│ persona + <knowledge_base> + <tuning> + full top-2 <documents>
│ + <tools> → agent loop (ls/read/grep; round-capped
│ + <tools> → agent loop (ls/read/grep, plus show_image when
│ BOR_ANSWER_IMAGES=1 and show_text when BOR_ANSWER_TEXT=1 — the two
│ "show the user an artifact" tools, gated independently; round-capped
│ BOR_AGENT_MAX_ROUNDS=10, 0 = tools off) → turbo streamed
└─ LOW (A8): DEFLECT_MODE prompt (weak titles only, no tools,
byte-identical direct-stream path) → turbo streamed
@@ -139,7 +160,15 @@ POST /api/chat {message, history?} (auth: require_user)
SSE frames (`data: <json>\n\n`): `thinking` (before first delta) →
`tool` (grounded turns, one per model call) → `retry` (pre-first-frame
restarts) → `delta` (answer tokens) → `done` `{deflected, sources[],
suggestions[], budget_exhausted?}` (terminal). Failure: `error` (terminal — no `done`, no
suggestions[], budget_exhausted?, answer_text[]?}` (terminal). **Phase 182
extends the `done` frame ADDITIVELY** — `answer_text` is the list of
server-resolved verbatim blocks (`locator`, `source`, `path`, `start`,
`end`, `total_lines`, `text`, `format`, `truncated`) the answer displayed;
there is **no eighth event type**, the key is OMITTED when no block was
displayed, and with `BOR_ANSWER_TEXT=0` it is absent on every turn (the
frame is then byte-identical to pre-phase). The same array persists on the
saved chat record, so restore and the anonymous shared page re-render the
panels from the server's bytes. Failure: `error` (terminal — no `done`, no
`query_log` row; a pre-stream DB outage is a plain 503 JSON). A
`tools=None` request never emits a `tool` frame; a round-capped turn
whose final reply carries no visible content answers with the budget
@@ -273,8 +302,11 @@ scripts.…`, and tests all honor it.
question=… embed_ms=… top_score=… fts_hits=… summary_hits=… tuning=N
kb_chars=N history_msgs=N threshold=… deflected=… sources=…
thinking_chars=… tool_calls=N rounds=N cap_hit=0|1 total_ms=…
retries=N scaffold_stripped=N
retries=N scaffold_stripped=N verbatim_blocks=N
```
(`verbatim_blocks=` — phase 182 — is the count of verbatim display blocks
actually SENT on the `done` frame, `0` on every turn without them,
deflected included.)
(`sources=` = retrieval + agent-read docs, deduped; `tool_calls=`
executed only; a cancelled turn writes neither line nor `query_log`
row.)
@@ -0,0 +1 @@
(no final assistant message — see the .err log)
@@ -0,0 +1 @@
error: no final assistant text
@@ -0,0 +1,143 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6849 17 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,10 @@
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**
- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
@@ -0,0 +1,143 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6849 17 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,19 @@
**Implemented (task 01 — settings + kill switch, inert)**
- `app/config.py`: `answer_text: bool = True` (master switch, default ON, docstring names the env var + the exact off-state), `show_text_max_chars: int = 12_000`, `show_text_max_blocks: int = 5`, plus two `@field_validator`s (`…_non_negative`) that fail startup naming the field; `0` accepted as inert.
- `app/core/settings_catalog.py`: three `SettingMeta` rows in the `Agent tools` group (`Answer text` / `Show text max chars` / `Show text max blocks`), plain — not secret, not restart-required, overridable.
- `.env.example`: `BOR_ANSWER_TEXT=true`, `BOR_SHOW_TEXT_MAX_CHARS=12000`, `BOR_SHOW_TEXT_MAX_BLOCKS=5` in the agent-tools block with the `0`/negative semantics (dotenv-parsed and verified).
- Tests: 5 new unit cases (`test_config.py`: defaults, env overrides, `0` inert, each negative refuses naming the field), 2 new catalogue pins (row group/label/flags + annotation/defaults), 1 new integration row-contract (`GET` shape, raw PUT → live, `0` storable, negative → 422 storing nothing, DELETE → default, anon 403), 1 new live-apply test (`_save` + `invalidate_settings()` → resolves without restart, reset returns defaults). No consumer reads the fields — no app behavior touched.
- Stale-doc fix in previously-completed work: hard-coded "78 items/rows/settings" prose (already wrong at 79) made generic in `app/api/admin_settings.py`, `tests/integration/test_admin_settings_api.py`, `tests/e2e/test_admin_settings_panel.py`, `tests/e2e/test_settings_mobile_value_width.py`.
**Gates (all green)**
- `uv run pytest` → **4274 passed** (unit + integration).
- `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%**; `app/config.py` 100%, `app/core/settings_catalog.py` 100%, `app/api/admin_settings.py` 100%.
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- `uv run pytest tests/e2e/test_admin_settings_panel.py -v --no-cov` → 12 passed in isolation (the generated panel renders the three new rows).
- Criteria checks: `get_settings()` prints `True 12000 5`; `BOR_SHOW_TEXT_MAX_CHARS=-1` / `BOR_SHOW_TEXT_MAX_BLOCKS=-1` each raise naming their field.
**Docs**: README env table — three new rows in *Agent tools* (ON-by-default rationale, line-boundary truncation, `0` = inert, negative refuses startup). No AGENTS.md change needed (it carries no env table; rule 5's verbatim-panel UI contract belongs to the phase's later/final task).
**Notable decisions**: budgets use the `>= 0` family (`0` = inert) rather than `> 0`, per LOCKED A8; block-cap error text is `must be >= 0 (blocks)`. No new consumers, no schemas/agent/prompts/frontend touched.
**Next pending task**: `.agents/phases/todo/182_verbatim_show_text/02_verbatim_resolver_module.md`.
@@ -0,0 +1,139 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 5%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 10%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 15%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 20%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 25%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
.......................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6615 17 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,21 @@
All gates green. Final report:
**Implemented**
- `app/rag/verbatim.py` (new, inert — nothing imports it yet): `LOCATOR_LANG`, frozen `VerbatimBlock` / `SliceResult`, `canonical_locator` (tolerant `source/path` + ` `/`:`/`#`/`#L` + `12-40`/`12–40`/`L12-L40`/bare `12` → one canonical `source/path#start-end`, refuses no-source, empty, non-positive/reversed/non-numeric bounds, trailing junk, leading `bor`), `fences_in` (order-preserving, first-occurrence dedupe, bare `` ```bor `` not swallowed, unclosed fence yields nothing, body discarded), `slice_lines` (`read`/`grep`-identical 1-based semantics, clamping, `past_end` + true total), `cap_slice` (line-boundary cut + shared `TRUNCATION_MARKER`, mid-line exception documented, `cap<=0` raises), `document_by_identity` (bare first-slash `(source, path)` lookup, no agent import), `block_for` (refuses missing/image/past-end; canonical locator incl. cap-derived range), `resolve_answer_blocks` (kill switches first, fences-then-unlocated-shown order, fence wins dedupe, block-budget truncation).
- One-way import rule enforced: no `app.rag.agent` import (AST test + clean `grep -n import`).
- `tests/unit/test_verbatim_resolver.py`: 79 tests (grammar, fences, slicing, capping, identity, block, resolver fusion, every kill-switch combination, AST import rule), house style — detached `Document` rows + stand-in session like `tests/unit/test_agent.py`.
**Verification**
- `uv run pytest tests/unit/test_verbatim_resolver.py -v` → 78→79 passed; `--cov=app.rag.verbatim` → **100%**
- `uv run pytest --cov=app --cov-report=term-missing` → **4353 passed**, TOTAL **99%** (>90%)
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors
- `python -c "…canonical_locator('homelab/a.md#L12-L40')"` → `homelab/a.md#12-40` ✓
**Docs**: no doc impact (task states nothing user-visible yet; the module is unwired — tasks 03/05 hook it in, phase's final pass re-checks README/AGENTS).
**Notes/deviations**
- Mid-session the repo was replayed under this run (HEAD now `814a400` phase 181, task 01 complete, `app/services/answer_text.py` superseded): I implemented the current task file's `app/rag/verbatim.py` spec against the live tree; earlier stale-tree work was discarded and left untouched.
- `canonical_locator` refuses a range on a whitespace-bearing identity (documented grammar limit) and keeps one defensive non-numeric-bound guard (tested via monkeypatched grammar) so the parser stays total.
- No live-infrastructure actions taken.
**Next pending task**: `.agents/phases/todo/182_verbatim_show_text/03_show_text_tool.md`
@@ -0,0 +1,141 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 33%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 81%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 94%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 454 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 155 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6770 17 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,9 @@
**Task 03 complete — `show_text` agent tool.**
- `app/rag/agent.py`: `SHOW_TEXT_TOOL` (exact task copy, after `SHOW_IMAGE_TOOL`), `agent_tools()` appends it when `answer_text` (offer order `ls, read, grep, show_image, show_text`; base three + pre-phase offers byte-identical), `AgentHolder.shown_texts`, and the `_execute_tool` branch in the exact 8-step order (path → flag-off `UNKNOWN_TOOL` → `_resolve_path` → A6 image refusal → reused `_read_range_args`/`BAD_RANGE_ARGS`/`OFFSET_PAST_END` → block budget before the work → `block_for` + canonical-locator dedupe → success result with the `read`/`show_image` header contract, `_origin_lines`, server-clamped `lines:` extent, `SHOWN_TEXT_TRUNCATION_NOTICE` on cap); module docstring extended with the A1–A3/A6 contract.
- `SHOW_TEXT_*` refusals: 4 constants, each used by exactly one branch (agent.py:2248/2265/2286/2310).
- Tests: +18 in `tests/unit/test_agent.py` (`-k show_text`: 18 passed) — offer matrix, tool-definition contract, refusal-copy pins, whole/ranged/capped shows, origin lines, 10 refusal cases with counting assertions, scripted-turn wiring (2nd line `Document …`, `lines: 3-5 of 5` matching the slice).
- Deliberate offer-array pin updates (each commented): `test_chat_gate.py`, `test_image_chips_and_answers.py`, `test_agent.py`, `test_chat_api.py`, `test_agent_tools.py`, `test_read_line_ranges_handbook.py`, e2e `test_answer_images.py`; leak-guard env pins added to `tests/conftest.py` + `tests/e2e/conftest.py` (`BOR_ANSWER_TEXT`/`_MAX_CHARS`/`_MAX_BLOCKS` code defaults).
- `README.md`: `show_text` row in the agent-tools table, new “Verbatim text display (phase 182)” section, cross-ref in the image table (settings rows already documented by task 01). No `chat_api.py`/`chat.js`/template touched.
- Gates: `uv run pytest` → **4371 passed**; coverage `app/` **99%** TOTAL (agent.py 99%, only pre-existing line 1312 missing; verbatim.py 100%); ruff + pyright clean; E2E in isolation: `test_answer_images.py` 5 passed, plus `agent_document_tools`/`harness_aligned_tools`/`tool_budget_telemetry`/`read_truncation_cap`/`search_tool_scope` all green.
- No git index/history mutations; phase stays in `todo/` (tasks 04–06 remain).
@@ -0,0 +1,141 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 28%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 156 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 155 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6810 17 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,15 @@
**Task 04 complete — `SHOW_TEXT_CLAUSE` taught, gated, byte-identical when off.**
- `app/rag/prompts.py`: new `SHOW_TEXT_CLAUSE` (locked 7-part copy: display contract → SEE/COPY/RUN → do-NOT-retype → ` ```bor <source/path> [range] ` with EMPTY body → server owns the numbers → refuse out loud → `show_image` pointer) + module-docstring paragraph.
- `build_high_prompt(show_text=…)`: clause rides INSIDE `<tools>` after `SHOW_IMAGE_CLAUSE` via one "section minus final `</tools>` + clauses + tag" mechanism; `TOOLS_SECTION` untouched; all 4 flag combos pinned.
- `app/api/chat.py`: grounded `TurnPlan` passes `show_text=settings.answer_text` beside `show_image`.
- Tests: +7 in `test_prompts.py` (locked sentence order, byte-identity when off, inside-`<tools>` placement, clause composition, deflect never carries `show_text`/`bor`), +2 sha/composition locks in `test_prompt_lock.py`, +1 grounded integration pin in `test_chat_api.py` (clause **and** tool offered together: 3 tools off / 4 on).
- Fixed pre-existing fallout of task 03 in prompt-reconstruction pins: `test_kb_overview_api.py` (×2), `test_image_chips_and_answers.py`.
- **Defect fixed (deviation, deliberate):** `tests/e2e/mock_llm.py`'s SHOW_IMAGE flow gated on the loose `"show_image" in system`, which the locked text clause now matches → phase-124 toggle-off E2E failed. Tightened to `_SHOW_IMAGE_CLAUSE_MARKER` (a sentence only the image clause carries); intent unchanged, ON app fires exactly as before. Mock triggers for phase 182 still deferred to task 08.
- Env note: a **stale `mock_llm` uvicorn from Oct 14 held port 8901** and served old mock code; killed it (test-double artifact, not live infra).
Gates: `uv run pytest --cov=app --cov-report=term-missing` → **4381 passed**, `app/` **99 %** (`prompts.py` 100 %). `uv run ruff check .` → clean; `uv run pyright` → 0 errors. E2E in isolation `--no-cov`: `test_answer_images.py`, `test_harness_aligned_tools.py`, `test_agent_document_tools.py`, `test_markdown_tables.py` all green. Criterion one-liner: `show_text=True` → `True`, default → `False`.
Docs: README "Verbatim text display (phase 182)" gained a *What the prompt teaches* bullet (copy-from-display rule + `bor` fence syntax + omit-the-range rule) and the off-state bullet now names the absent clause; SEE/COPY/RUN wording aligned. No AGENTS.md change (its rule-5 UI list lands with tasks 06/08).
Next pending task: `.agents/phases/todo/182_verbatim_show_text/05_done_frame_and_persistence.md`.
@@ -0,0 +1,141 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 18%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 23%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 36%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 41%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 54%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 88%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 96%]
........................................................................ [ 98%]
............................................................. [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 280 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 155 0 100%
app/schemas.py 533 0 100%
--------------------------------------------------
TOTAL 6816 17 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 05 complete — all gates green.**
**Implemented**
- `app/schemas.py`: new `VerbatimBlockRef` (source/path/title/start_line/end_line/total_lines/text≤600 000/truncated/locator≤2100); `ChatDoneEvent.answer_text` + serializer POPs the key when `None`/empty (absent, never `null`/`[]`); `ChatMessage.answer_text` (cap 50) dropped by `_drop_image_when_absent`; docstrings in the `answer_image` style incl. the two deliberate differences (list + inline bytes, A4).
- `app/api/chat.py`: `visible_parts` accumulator beside `content_chars` (same post-filter pieces, never `thinking`); done-time `resolve_answer_blocks(...)` in a short-lived session, **skipped entirely on deflected turns** (D9), degrading to no-blocks + a named log line if the KB drops mid-answer; `answer_text=blocks or None`; per-turn line gains `verbatim_blocks=%d`; module docstring paragraph.
- Defect fixes in task 02's `app/rag/verbatim.py` (found by the new frame tests): `cap_slice` under-counted a slice ending on a blank line (`splitlines()`), so the caption lied by one line **and** the locator mismatched the same range resolved by `show_text`, making one block paint twice; dedupe now keys on the resolved locator as well as the written canonical; fence scan stops once `show_text_max_blocks` blocks are in (bounded lookups on the loop).
- `README.md`: `done.answer_text` wire contract + deflected/off semantics, payload-limit rows; `AGENTS.md` rule 10: `verbatim_blocks=N`.
**Gates**
- `uv run pytest` → exit 0 (4398 collected); `uv run pytest --cov=app --cov-report=term-missing` → `app/schemas.py` 100 %, `app/rag/verbatim.py` 100 %, `app/api/chat.py` 99 % (only pre-existing miss: the SEC-14-04 concurrency 503 pre-check), TOTAL 99 %.
- `uv run ruff check .` clean; `uv run pyright` 0 errors.
- E2E in isolation (`--no-cov`, exit 0): `test_answer_images.py`, `test_harness_aligned_tools.py`+`test_agent_document_tools.py`+`test_markdown_tables.py`, `test_chat_persistence.py`/`test_share_chat.py`/`test_chat_rag.py`.
- New tests: 3 gate unit + field-order pin, 8 chat-API integration (tool block, fence-only, fence+tool dedupe, max_blocks=2 order, byte-identity, `answer_text=False`, deflected, resolution-failure degradation), 4 chats-API persistence, 6 schema, 2 resolver defect pins.
- Byte budget (phase-167 audit): observed `api method=POST path=/api/chat status=200 bytes=1272 enc=identity stream=1` on a `show_text` turn; worst-case frame (5 × 12 000-char blocks + long paths) ≈ 65 KB ≈ 25 % of the 256 KB budget — no concern.
**Deviations to note**
1. `_require_within_total_budget` now also sums block bytes (task work-item 5 said existing validators don't change): `POST/PUT /api/chats` is anonymous (phase 55) and `answer_text[].text` carries the 600 000 cap, so without this the 8 000 000 SEC-05 invariant gains an unbounded second byte channel. Tightening only — no legitimate record (5 × 12 000 = 60 KB) is affected; pinned by `test_saved_chat_total_budget_counts_verbatim_block_text`.
2. The `verbatim.py` fixes above touch task 02's module (behaviour bug, not a spec change) — pinned in `tests/unit/test_verbatim_resolver.py`.
**Next pending task:** `.agents/phases/todo/182_verbatim_show_text/06_markdown_locator_and_panel_css.md`.
@@ -0,0 +1,142 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 13%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 26%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 31%]
........................................................................ [ 32%]
........................................................................ [ 34%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 39%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 44%]
........................................................................ [ 45%]
........................................................................ [ 47%]
........................................................................ [ 49%]
........................................................................ [ 50%]
........................................................................ [ 52%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 57%]
........................................................................ [ 58%]
........................................................................ [ 60%]
........................................................................ [ 62%]
........................................................................ [ 63%]
........................................................................ [ 65%]
........................................................................ [ 66%]
........................................................................ [ 68%]
........................................................................ [ 70%]
........................................................................ [ 71%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 76%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 84%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 98%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6849 17 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,24 @@
**Task 06 complete — all gates green.**
**Implemented**
- `frontend/assets/markdown.js`: `renderMarkdown(md, opts)` (2nd optional arg, `opts && opts.verbatim === true` the only truthy path); new step-0 verbatim pass BEFORE the fence pass using the pinned regex; body/info line/closer dropped, `\u0000VERBATIMn\u0000` placeholder restored as `<div class="verbatim verbatim-pending" data-bor-locator="…">` + `verbatim-caption` + `role="status"` “Retrieving the exact text…”, everything through `escapeHtml`; unparseable locator returned byte-for-byte to the fence pass; new local `borLocator()` (client mirror of `app.rag.verbatim.canonical_locator`); paragraph guard gained the placeholder clause (pre-phase guard text untouched); header comment rewritten.
- `frontend/assets/styles.css`: one labelled phase-182 region — `.verbatim`, sticky `.verbatim-head`, clipped `.verbatim-caption`, text `.verbatim-badge` (`--ok`, `text-transform: none`), `.verbatim-body` (`max-height: min(40rem,70vh)`, `overflow:auto`, `white-space: pre`, `tab-size: 2`, `overflow-wrap: normal`), `user-select: none` `.verbatim-lines` gutter, `.verbatim-pending`/`.verbatim-state`, `.verbatim-unresolved` (badge hidden), `.code-block` + `.code-block-copy`/`.verbatim-copy` (pill, 3px focus ring, ellipsis label, `.copied`); palette vars only, nothing animates; `.code-block-copy`/`.verbatim-copy` lifted to 44px in the ≤640 block.
- New tests: `tests/unit/test_frontend_verbatim_markdown.py` (22: source pins + node behavioral + JS↔Python parity table + 400-case deterministic fuzz), `tests/unit/test_frontend_verbatim_css.py` (20).
**Gates**
- `uv run pytest` → **4449 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`app/rag/verbatim.py` 100%, no new `app/` code); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- New files in isolation: `uv run pytest tests/unit/test_frontend_verbatim_markdown.py tests/unit/test_frontend_verbatim_css.py -v` green.
- E2E in isolation (`--no-cov`): `test_markdown_tables.py`, `test_markdown_blockquotes.py`, `test_markdown_inline_links_images.py`, `test_document_viewer.py`, `test_answer_images.py`, `test_thinking_display.py`, `test_background_no_motion.py` all green.
- Live browser check against the served app: `{verbatim:true}` → `data-bor-locator="homelab/a.md#12-40"`, no `GARBAGE` in the HTML and none in the painted DOM; sticky head, 70vh-capped scroll body, `user-select: none` gutter, badge `VERBATIM`, copy pill 32px desktop → 44px at 375px.
**Docs**: README — new chat-section paragraph (opt-in pass + pass order) and a “The client never paints the model’s copy” bullet in §Verbatim text display; AGENTS.md rule 5 — the verbatim-panel contract (“verbatim-and-badged OR plainly the model’s”).
**Decisions / deviations**
1. Criterion #2’s “OFF returns the ordinary `<pre><code>`”: a ` ```bor <path> ` info string has *never* matched the fence rule (`/```(\w*)\n/`), so OFF is escaped prose — pinned byte-identical to pre-phase; a bare ` ```bor ` fence still yields `<pre><code>`. Widening the fence rule was rejected (it would change every existing call site, violating the byte-identity mandate).
2. Implemented the pinned regex verbatim: `(?![\s\S])` covers a fence open exactly at end-of-input (info line typed, body not yet streamed); a half-typed *body* renders as ordinary text until its closer arrives — same as every incomplete fence today. Documented in header + code comment.
3. Added the `VERBATIM`-placeholder clause to the paragraph guard so the restored `<div>` isn’t wrapped in `<p>`; the pre-phase guard string is untouched (phase-180 pin stays green).
4. Went beyond the task’s testing note by pinning locator parity in a unit test (table + deterministic fuzz) — parity is hydration-critical and checkable without a browser.
5. Dropped `scrollbar-color` from `.verbatim-body`: `test_prose_word_wrap.py` pins scrollbar theming as modal-only (phase 116).
6. Fixed a stale pin in `tests/unit/test_document_viewer.py` (`function renderMarkdown(md)` → `function renderMarkdown(md`), broken by the new signature.
**Next pending task**: `182_verbatim_show_text/07_hydration_and_copy_buttons.md` (opts the three answer renderers in, adds `verbatim.js` hydration + Copy buttons).
@@ -0,0 +1,142 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 21%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 29%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 37%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 42%]
........................................................................ [ 43%]
........................................................................ [ 45%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 50%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 55%]
........................................................................ [ 56%]
........................................................................ [ 58%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 63%]
........................................................................ [ 64%]
........................................................................ [ 66%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 71%]
........................................................................ [ 72%]
........................................................................ [ 74%]
........................................................................ [ 76%]
........................................................................ [ 77%]
........................................................................ [ 79%]
........................................................................ [ 80%]
........................................................................ [ 82%]
........................................................................ [ 84%]
........................................................................ [ 85%]
........................................................................ [ 87%]
........................................................................ [ 89%]
........................................................................ [ 90%]
........................................................................ [ 92%]
........................................................................ [ 93%]
........................................................................ [ 95%]
........................................................................ [ 97%]
........................................................................ [ 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/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6849 17 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,25 @@
All gates are green. Final state of the work:
## What landed (task 07 — hydration, panels, Copy buttons)
**New client — `frontend/assets/verbatim.js`** (531 lines, classic script, globals like `markdown.js`)
- `buildVerbatimPanel(block, opts)` → sticky `.verbatim-head` (`.verbatim-caption` `source/path · lines 12–40 of 210` / `… · all 210 lines`, `.verbatim-badge` `VERBATIM`, `.verbatim-open`, `.verbatim-copy` `Copy N lines from <source/path>`), `.verbatim-body` = `ol.verbatim-lines` gutter + `pre.verbatim-text` (`pre.textContent = block.text`), `.verbatim-more` on a capped block; root `role="group"` + `aria-label` + `data-bor-locator` = the **server's** locator.
- `hydrateVerbatim` (exact canonical-locator `===`, else exactly one block on the same `source/path`, else the pinned unresolved sentence — never throws, idempotent because only `.verbatim-pending` is touched), `appendUnlocatedVerbatim` (the `answer_image` band), `settleVerbatimPlaceholders`, `enhanceAnswerCodeBlocks` (`.code-block` + Copy, **no badge**, idempotent), `copyTextWithFallback`.
- **No second locator grammar** (no `borLocator`, no range regex, no `parseInt`) — pinned as the phase's drift guard. All `createElement`/`textContent`, all `addEventListener`, zero `innerHTML`.
**Wiring** — `index.html` + `shared.html` load it after `markdown.js`; `Containerfile` minifies it without `--bundle`. `app.js`: both live answer renderers opt in, the `done` handler runs image → hydrate → unlocated → Copy-settle → sources, `answer_text: ev.answer_text || undefined` joins the save point, the restore path re-renders + hydrates from `m.answer_text`, five terminal settle sites (`error` frame, 300 s guard, its abort surface, Stop, the failed-turn funnel), `VERBATIM_LINK_OPTS` (`documentUrl(…, "/")` + `openDocumentModal`), and the share copy now **delegates** to the one clipboard helper. `shared.js`: same three globals + a plain `/document.html` href, no modal. `styles.css`: extended only inside the labelled phase-182 region (`.verbatim-body` flex row, `.verbatim-text`, `.verbatim-open`, `.verbatim-more`, 44 px mobile link) — palette variables only.
## Verification
- `uv run pytest tests/unit tests/integration` → **4483 passed**; `--cov=app` → **TOTAL 99 %** (>90 %).
- `ruff check .` → all checks passed; `pyright` → **0 errors**.
- New `tests/unit/test_frontend_verbatim_blocks.py`: **34 tests** (source pins + a node half that runs caption/gutter/match/block-guard logic against the real file).
- Stale pins reconciled (byte-pin `PINNED_RUN_TURN` regenerated, 3 brain-restore shapes, the task-06 call-site test rewritten as *only the answer renderers opted in*, the share-copy internals re-pinned at their new home, `shared.html` script order, `renderMarkdown(finalText, { verbatim: true })`).
- Playwright regression pass: smoke, share_chat, chat_answer_clusters, chat_persistence, prose_word_wrap, no_empty_box, save_share_ux, failed_turn_retry, answer_images, long_answers, reasoning_bubble, edit_and_resend, chat_error_honesty — all green.
- Real-browser sanity check (throwaway script, not committed): caption/badge/gutter/Copy at 1280 px and 375 px, gutter stays put while the body scrolls sideways, **clipboard equals the server string exactly**, `Copy`→`Copied`, plain code block copies with no badge, unresolved shows the sentence with no badge/body/`<pre>`, no page overflow at 375 px, 44 px tap targets, 3 px `:focus-visible` ring.
**Docs**: README (panel anatomy, the hydration rule, unresolved/settle behaviour, Copy-on-every-settled-block, parity, the shared-page exception, renderer opt-in count) and AGENTS rule 5's UI contract line.
## Notes
- Nothing committed and nothing staged — child passes never touch the index/history; the harness makes the one atomic phase commit.
- Environment gotcha worth knowing: a stray `python -m http.server` on port 8123 makes every E2E suite fail with `404`/`Sign-in failed (HTTP 501)`; I killed the one my own throwaway check left behind.
- For task 08's E2E, the behaviours already pinned at source level that want browser assertions: exactly one `.verbatim` per locator after a reload, clipboard equality, the unresolved copy, nothing at *Retrieving the exact text…* after `slow_llm`/always-fail, the appended unlocated block above the source clusters, and shared-page parity with no other controls.
@@ -0,0 +1,143 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6849 17 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,32 @@
**Task 08 complete.** Everything is verified green; nothing staged or committed (the pipeline owns the commit and the task-file move).
## Delivered
| File | Change |
|---|---|
| `tests/e2e/test_verbatim_show_text.py` | **new**, 1 428 lines — the phase's dedicated Playwright suite (13 specs) |
| `tests/e2e/mock_llm.py` | +402 lines — the three scripted `show_text` branches, the corrupted-fence-body generator, the fixture doc-picker, module docstring |
| `.agents/PLAN.md` | §4 fifth tool + additive `done.answer_text`, §9 `verbatim_blocks=N`, revisions paragraph incl. the explicit **A1–A6 / A15 otherwise UNCHANGED** statement |
| `README.md` | two precision fixes to the `BOR_ANSWER_TEXT=0` wording (server surface is pre-phase; the client's never-paint-the-model's-copy rule is flag-independent) |
Suite coverage: tool flow with a deliberately corrupted model fence, server-bytes/caption/gutter/badge/viewer link, five-tool offer + one executed call, in-page SSE tap (`≤7` event types, `answer_text` inside `done`), append path above the source clusters, clipboard byte-equality for `.verbatim-copy` and `.code-block-copy`, standalone fence with zero tool calls, honest state for a hallucinated locator, wrong range corrected to the server's extent, `OFFSET_PAST_END` refusal, reload + **`/history` → `/?chat=<id>` (local storage cleared, so the pass cannot be vacuous)** + anonymous shared page, image regression + `show_text`-on-image refusal naming `show_image`, 390 px scroll/44 px controls, and App B (`BOR_ANSWER_TEXT=false`).
## Gates
| Gate | Result |
|---|---|
| `pytest tests/e2e/test_verbatim_show_text.py --no-cov` (isolated) | **13 passed** in 28 s |
| Regression sweep (all 14 named suites + phase 180's, isolated) | **14/14 rc=0**, no edits to existing specs |
| `pytest --cov=app` (unit + integration) | all pass, `app/` coverage **99%** (17 missed / 6 849) |
| `ruff check .` | All checks passed |
| `pyright` | 0 errors |
| Phase-116 trigger discipline | 41 triggers: no containment involving a new phrase; no other suite contains one |
| **Mutation check (suite has teeth)** | 5/5 caught: drop the live `{verbatim:true}` opt-in · Copy writes wrong string · honest sentence muted · append path no-op'd · panel body swapped for the title |
## Flagged (per operating rule 3 — not silently deviated)
1. **App B fence rendering.** Task 08's bullet expects a ` ```bor ` fence with the flag off to render as an ordinary `<pre><code>` block. That contradicts the contract locked by task 07 and recorded in `AGENTS.md` rule 5 ("an unresolvable locator painting the pinned sentence… with no badge, no body and **none of the model's bytes**"). I asserted the **locked** behavior (honest sentence + Copy on the ordinary block) and corrected the README wording; pre-phase `<pre><code>` for a `bor` body would re-open the drift incident.
2. **One existing mock branch was NOT byte-identical.** Phase 182's `SHOW_TEXT_CLAUSE` ends "For an image use `show_image` instead.", so phase 124's gate `if "show_image" not in system` now also matches a text-clause-only prompt and the image flow fired on `BOR_ANSWER_IMAGES=0` apps. Tightened to `_SHOW_IMAGE_CLAUSE_MARKER = "displays a standalone image document"` (a sentence only the image clause carries, `prompts.py:394`); `test_answer_images.py` green.
3. **Trigger constant names** are public (`SHOW_TEXT_TRIGGER`, `SHOW_TEXT_INLINE_TRIGGER`, `SHOW_TEXT_MISSING_TRIGGER`) rather than the task's `_FENCE_ONLY_TRIGGER`/`_BAD_LOCATOR_TRIGGER`, matching the mock's convention for constants suites import; semantics, phrases and flow order are exactly as specified. `_SHOW_TEXT_CONFIRM_MARKER` keeps its task name.
Incident note for transparency: an earlier shell-quoting mistake of mine silently stripped `{ verbatim: true }` from `app.js:4363`; it was restored and the file is byte-verified (md5 `33a584f3…`), with the full suite, the E2E suite and the mutation harness all re-run against the restored tree.
@@ -0,0 +1,143 @@
........................................................................ [ 1%]
........................................................................ [ 3%]
........................................................................ [ 4%]
........................................................................ [ 6%]
........................................................................ [ 8%]
........................................................................ [ 9%]
........................................................................ [ 11%]
........................................................................ [ 12%]
........................................................................ [ 14%]
........................................................................ [ 16%]
........................................................................ [ 17%]
........................................................................ [ 19%]
........................................................................ [ 20%]
........................................................................ [ 22%]
........................................................................ [ 24%]
........................................................................ [ 25%]
........................................................................ [ 27%]
........................................................................ [ 28%]
........................................................................ [ 30%]
........................................................................ [ 32%]
........................................................................ [ 33%]
........................................................................ [ 35%]
........................................................................ [ 36%]
........................................................................ [ 38%]
........................................................................ [ 40%]
........................................................................ [ 41%]
........................................................................ [ 43%]
........................................................................ [ 44%]
........................................................................ [ 46%]
........................................................................ [ 48%]
........................................................................ [ 49%]
........................................................................ [ 51%]
........................................................................ [ 53%]
........................................................................ [ 54%]
........................................................................ [ 56%]
........................................................................ [ 57%]
........................................................................ [ 59%]
........................................................................ [ 61%]
........................................................................ [ 62%]
........................................................................ [ 64%]
........................................................................ [ 65%]
........................................................................ [ 67%]
........................................................................ [ 69%]
........................................................................ [ 70%]
........................................................................ [ 72%]
........................................................................ [ 73%]
........................................................................ [ 75%]
........................................................................ [ 77%]
........................................................................ [ 78%]
........................................................................ [ 80%]
........................................................................ [ 81%]
........................................................................ [ 83%]
........................................................................ [ 85%]
........................................................................ [ 86%]
........................................................................ [ 88%]
........................................................................ [ 89%]
........................................................................ [ 91%]
........................................................................ [ 93%]
........................................................................ [ 94%]
........................................................................ [ 96%]
........................................................................ [ 97%]
........................................................................ [ 99%]
................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
--------------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/admin_settings.py 114 0 100%
app/api/auth.py 52 0 100%
app/api/chat.py 292 1 99%
app/api/chat_images.py 52 0 100%
app/api/chats.py 111 0 100%
app/api/config.py 13 0 100%
app/api/doc_drafts.py 99 0 100%
app/api/docs.py 239 1 99%
app/api/git_sources.py 259 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 33 0 100%
app/api/sync.py 204 0 100%
app/api/tokens.py 40 0 100%
app/api/ui_settings.py 55 0 100%
app/config.py 304 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/compression.py 59 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 43 0 100%
app/core/errors.py 5 0 100%
app/core/http_cache.py 22 0 100%
app/core/logging.py 13 0 100%
app/core/rate_limit.py 44 0 100%
app/core/response_budget.py 63 0 100%
app/core/security_headers.py 20 0 100%
app/core/settings_catalog.py 18 0 100%
app/core/settings_migrate.py 32 0 100%
app/core/settings_overlay.py 111 0 100%
app/core/theming.py 40 0 100%
app/core/tokens.py 44 0 100%
app/db.py 22 0 100%
app/main.py 105 0 100%
app/models.py 141 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 494 1 99%
app/rag/archive_upload.py 134 0 100%
app/rag/chunker.py 220 4 98%
app/rag/doc_dates.py 18 0 100%
app/rag/folder_summaries.py 134 0 100%
app/rag/git_sources.py 77 0 100%
app/rag/importer.py 855 4 99%
app/rag/llm.py 253 1 99%
app/rag/md_images.py 63 0 100%
app/rag/overview.py 78 0 100%
app/rag/pdf.py 192 0 100%
app/rag/prompts.py 162 0 100%
app/rag/retriever.py 252 3 99%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 58 0 100%
app/rag/svg_text.py 111 0 100%
app/rag/verbatim.py 160 0 100%
app/schemas.py 549 0 100%
--------------------------------------------------
TOTAL 6849 17 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+3
View File
@@ -104,6 +104,9 @@ BOR_NAME_HIT_BONUS=0.005 # selection-time bonus a name-hit doc's best f
# --- 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); every tool result carries the model's remaining count (phase 160)
# BOR_READ_MAX_CHARS=128000 # cap on a `read` result (chars); over-cap reads truncate + a notice that teaches the grep -> read(offset, limit) workflow (phase 95, rewritten phase 131)
BOR_ANSWER_TEXT=true # verbatim file display (phase 182): the master switch, ON by default (an answer's code block must never silently differ from the file). false = the escape hatch — the `show_text` tool is not offered, its <tools> teaching clause is absent, ```bor locator fences are not resolved, and the done frame carries no `answer_text` key (every byte-identity contract is exactly pre-phase)
BOR_SHOW_TEXT_MAX_CHARS=12000 # per-block char budget for ONE displayed verbatim slice (phase 182); the slice is cut at a LINE BOUNDARY + the shared […truncated…] marker and the caption's line range shrinks to what is actually shown; 0 = inert (no block displays), negative fails startup naming the field
BOR_SHOW_TEXT_MAX_BLOCKS=5 # per-answer cap on displayed verbatim blocks (phase 182), counted in answer order; 0 = inert (no block displays), negative fails startup naming the field
# --- Import scope (A9 default; ANY well-formed extension is allowed) ---
# Comma-separated file extensions (lowercase, no dot) the importer reads.
+28 -2
View File
@@ -47,7 +47,30 @@
in-flight row (`role="status"`) / alert + Retry row
(`role="alert"`, a real `<button>`) sits inside that same bounded
scroll panel — and the stat cards read the server's `totals`,
never a walk of a depth-limited tree (phase 167)) and
never a walk of a depth-limited tree (phase 167); an answer's
code block is EITHER verbatim-and-badged OR plainly the model's —
never a silently-substituted hybrid: the ` ```bor ` locator fence
renders through the ONE shared renderer as
`.verbatim[data-bor-locator]` with the model's body DROPPED
(`renderMarkdown(md, { verbatim: true })`, off by default so every
other surface is byte-identical), styled from palette variables only
with a `min(40rem, 70vh)` scroll body, a sticky `source/path`
caption and a `user-select: none` line-number gutter, while the
plain `.code-block` wrapper carries a Copy button and no badge
(phase 182); those placeholders hydrate against the frame's
`answer_text` through the ONE panel client
(`frontend/assets/verbatim.js` — a classic script loaded AFTER
`markdown.js` on the chat AND shared pages, no second locator grammar,
a plain string compare against the server's canonical `locator`, the
caption's numbers always the SERVER's extent, an unresolvable locator
painting the pinned sentence `I could not verify this against the
knowledge base` with no badge, no body and none of the model's bytes,
no panel left at "Retrieving the exact text…" after a terminal frame
(the `error` frame, the 300 s guard, Stop and the failure funnel all
settle it), the Copy button's clipboard content equal to the server
string exactly, and the shared page's panel Copy button + plain
`/document.html` link the ONE owner-directed exception to that page's
zero-controls rule) — phase 182 task 07) and
meets WCAG 2.1 AA basics (semantic
landmarks, labels, contrast ≥4.5:1, focus-visible, aria-live for the
stream).
@@ -70,7 +93,10 @@
Playwright E2E file passes **in isolation**
(`uv run pytest tests/e2e/test_<story>.py -v --no-cov`).
10. **Amply log, visibly feed back:** server-side, log the required
per-turn line (PLAN §9) **and** the per-response byte-budget line
per-turn line (PLAN §9 — phase 182 appends `verbatim_blocks=N` after
`scaffold_stripped=N`: the verbatim display blocks actually SENT on
the `done` frame, `0` on every turn without them, deflected included)
**and** the per-response byte-budget line
(`app/core/response_budget.py` — one
`api method=… path=… status=… bytes=… enc=… duration_ms=…` line per
`/api/*` response, INFO at/over `BOR_LOG_LARGE_RESPONSE_BYTES`, read it
+1
View File
@@ -23,6 +23,7 @@ RUN mkdir -p /out/assets \
&& esbuild ./assets/doc-edit.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/doc-edit.js \
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/verbatim.js --minify --outfile=/out/assets/verbatim.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
+174 -5
View File
@@ -34,6 +34,8 @@ uv sync
# 2. Configure (copy and edit)
cp .env.example .env
# Set BOR_LLM_API_KEY — your aipi key (falls back to $AIPI_KEY)
# Set BOR_ADMIN_PASSWORD and BOR_SESSION_SECRET — both are REQUIRED:
# the app refuses to start while either is empty (see Admin & Sign-in)
# 3. Start the database
podman compose up -d db
@@ -162,6 +164,22 @@ collapses into body text (the UA default is 0.83em/0.67em) nor out-shouts
the answer around it, in the bubble, the thinking block and the viewer
alike.
**The renderer has one more pass, and it is opt-in** (phase 182):
`renderMarkdown(md)` is now `renderMarkdown(md, opts)`, and the ONLY truthy
option is `{ verbatim: true }`, passed by exactly the FOUR answer renderers —
the live per-delta write, the `done`-time re-render, the chat page's restore
and the shared page's record renderer. Every other call site omits it, so the
feature is inert elsewhere. With it
on, a ` ```bor <source/path> <range> ` locator fence is
replaced — fence line, body and closer included — by the pending verbatim
panel, so the model's re-typed copy of a file is never painted (see
[Verbatim text display](#verbatim-text-display-phase-182)). Pass order with
the option on is **verbatim → fences → tables → escape+inline → blockquotes →
paragraphs → restore**; with it off — the document viewer, the thinking runs,
and every question bubble on both pages — the output is byte-identical
to pre-phase, so a document that legitimately contains a ` ```bor ` fence
still renders its own text.
The document's meta row (the chat modal **and** the standalone viewer at
`/document.html`) carries a **Copy path** button: one click copies the
document's `source/path` (the exact form the `read` tool, the chips, and
@@ -296,7 +314,15 @@ The overlay can be switched off for the whole process with
### Shared chat (`/shared/<token>`)
A read-only snapshot of any conversation, shareable via a link. Open to
everyone — no login or token required.
everyone — no login or token required. The page still ships no composer, no
Save/Share/Tune/Retry and no chips-as-buttons; the ONE interactive exception is
phase 182's answer code: a shared verbatim panel and the answer's ordinary
code blocks carry a **Copy** button and the panel a plain
`/document.html?source=…&path=…` link, because a page that shows a slice of a
file has to let the reader copy it and check it. Both come from the same
`verbatim.js` the chat page calls (so a shared turn renders identically to the
owner's), and a guest who follows the viewer link lands on the sign-in page —
the documents API is admin-gated.
### Conversation payload limits
@@ -311,7 +337,9 @@ saving a conversation can never be stricter than continuing it (phase
| One message (`text`, `thinking`) | 600 000 chars | a **full-length answer at the largest answer cap** — `BOR_MAX_OUTPUT_TOKENS` at 131 072 tokens ≈ 524 300 chars — as history and as a saved record, with margin (the old 32 000 ceiling broke even at the 32 768-token default) |
| Turns sent as `history` | 500 | the whole browser conversation (the client sends everything it holds, minus the question being asked) |
| Messages per saved chat | 500 | the same conversation, saved |
| **Total chars per saved chat** | **8 000 000** | ~15 full-length answers; the one bound on the anonymous save surface, which stays bounded on purpose |
| One verbatim block (`answer_text[].text`) | 600 000 chars | the same answer-text cap (phase 182) — comfortably above `BOR_SHOW_TEXT_MAX_CHARS`, so raising that budget never hits the wire limit |
| Verbatim blocks per saved message | 50 | the `tools` / `segments` number (phase 182) — far past the `BOR_SHOW_TEXT_MAX_BLOCKS` default of 5 |
| **Total chars per saved chat** | **8 000 000** | ~15 full-length answers; the one bound on the anonymous save surface, which stays bounded on purpose — and it counts the verbatim blocks' bytes too (phase 182), so the display blocks cannot become a second, unbounded byte channel |
| One question (`message`) | 4 000 chars | unchanged; the composer counts it live |
These are sanity ceilings, not the prompt's budget: the real gate is
@@ -675,8 +703,10 @@ server-side tools — on **grounded** (high-relevance) turns only:
| **`read(path, offset?, limit?)`** | Appends one indexed document's text to context by its combined `source/path`; optional `offset` (1-based first line) + `limit` (line count) read just that part of a large document; very large reads are truncated at `BOR_READ_MAX_CHARS` with a notice that teaches the workflow — grep reports line numbers, then read that part with `offset` + `limit` |
| **`grep(pattern, path?)`** | Searches indexed documents for an exact string (case- and accent-insensitive fixed substring — accents fold: `besta` matches `BESTÅ`, phase 162); returns up to 20 `source/path:line: text` matches; an optional `path` limits to one document |
| **`show_image(path)`** | *(only when `BOR_ANSWER_IMAGES=1`)* shows one standalone image document as part of the answer — image documents only, at most one per answer |
| **`show_text(path, offset?, limit?)`** | *(only when `BOR_ANSWER_TEXT=1`, the default)* displays the named range of a text document to the user **verbatim** — the server shows the bytes and keeps them byte-identical; the model describes them, never retypes them; image documents are refused (use `show_image`); per-answer display budgets enforced server-side |
(See [Answer images](#answer-images-phase-124) for the `show_image` tool.)
(See [Answer images](#answer-images-phase-124) for the `show_image` tool and
[Verbatim text display](#verbatim-text-display-phase-182) for `show_text`.)
**The prompt teaches the search discipline (phase 162).** A summary or an
outline is a hint, never an inventory: the system prompt tells the model to run
@@ -734,7 +764,7 @@ instead of an error frame (phase 160, the recorded revision of the
phase-71 cap-path terminal). The `done` frame carries an additive
`budget_exhausted` key (ABSENT on every non-cap turn) that drives the
**Continue researching** button on the answer's meta row — see the
[Chat (`/`)](#chat--) section.
[Chat (`/`)](#chat-) section.
---
@@ -971,6 +1001,142 @@ of its answer:
---
## Verbatim text display (phase 182)
The mirror of the answer-image trust model, for words: when the user asks to
see part of a document — a stanza, a passage, an exact range of lines — the
model **names the location and the server shows the bytes, byte-for-byte**.
The model never retypes quoted text, so a displayed quote cannot drift.
- **The model decides.** The grounded agent is offered a display tool,
`show_text(path, offset?, limit?)`, and calls it when the user wants to
SEE, COPY or RUN a file — a quadlet, a config, a script, a unit file —
or read its exact wording. Optional
`offset` (1-based first line) and `limit` (line count); an overshooting
`limit` is clamped, an `offset` past the last line is refused with the
true line count.
- **What the prompt teaches.** With the switch on, the grounded `<tools>`
section gains one clause (after the `show_image` clause when that rides
too) saying the two things the tool description cannot: the user copies
from **what is displayed**, not from the model's prose — so after a
`show_text` call the model describes the lines instead of retyping them
— and the inline locator fence for placing a displayed block mid-answer:
a line of three backticks + `bor` + the combined `source/path` + an
optional `12-40` range, with an **empty body** the app replaces with the
stored text. Line numbers come from `grep`/`read`, never from a guess —
when unsure, the range is omitted and the whole document is displayed.
`BOR_ANSWER_TEXT=0` removes the clause too, so the prompt never teaches
a tool the model was not given.
- **Server-side budgets.** At most `BOR_SHOW_TEXT_MAX_BLOCKS` blocks per
answer (a second identical show is refused — it is already on screen);
each block is capped at `BOR_SHOW_TEXT_MAX_CHARS`, cut at a line boundary
with a truncation marker. Image documents are refused with a pointer to
`show_image` — the two display tools never convert into each other.
- **The bytes ride the `done` frame.** A turn that displayed text ends
with one extra, additive SSE key: `done.answer_text`, the list of blocks
in **display order** — answer order first (one per ` ```bor ` fence, the
fence controls placement), then the `show_text` blocks the answer never
located. Each entry is
`{source, path, title, start_line, end_line, total_lines, text, truncated, locator}`
where `text` is the **server's** slice of `documents.content` (never
model output) and `start_line`/`end_line` are the server's resolved
1-based inclusive extent — the numbers a caption shows can never be the
model's claim. `locator` is the canonical matching key
(`source/path`, or `source/path#12-40` for a range). There is no new
bytes route and no eighth SSE event type: the bytes travel inline, and a
saved or shared chat renders its panels from its own record. The same
array persists on the saved brain record (`bor.chat.v1` `answer_text`),
so a reload and an anonymous shared page show the same blocks.
- **Deflected turns resolve nothing.** A ` ```bor ` fence written into a
deflected answer leaks no KB text — the disclaimer turn resolves no
block and its frame carries no `answer_text` key.
- **Off = pre-phase.** `BOR_ANSWER_TEXT=0` removes the tool from every
offer **and** its `<tools>` clause (the grounded system prompt is
byte-identical to pre-phase); answers return to composed text (the model
quoting from what it read). A turn with no blocks serializes **without**
the key — absent, never `null`, never `[]` — so every block-free frame
and every pre-phase saved record keeps its exact pre-phase bytes. The
SERVER surface is byte-pre-phase; the CLIENT rule is flag-independent, so
a stray ` ```bor ` fence in a flag-off answer still cannot paint the
model's copy — with no `answer_text` to hydrate it, it settles to the same
honest sentence (the phase E2E's App B spec).
- **The client never paints the model's copy.** The one shared markdown
renderer (`frontend/assets/markdown.js`) takes a SECOND, optional
argument — `renderMarkdown(md, { verbatim: true })` — and with it ON a
` ```bor ` locator fence is replaced, info line, body and closing fence
included, by a pending panel:
`<div class="verbatim verbatim-pending" data-bor-locator="…">` holding the
`source/path` caption and the live-region line *Retrieving the exact
text…*. The locator is normalized client-side by the exact mirror of the
server's `canonical_locator` (`source/path`, or `source/path#12-40` —
digits only, so the two sides match on a string compare), the model's
bytes appear NOWHERE in the output, and a fence the model has not finished
typing shows that same pending state instead of raw backticks. An
unparseable locator degrades to an ordinary code block with the model's own
body — no placeholder, no badge. With the argument OFF — every other
surface in the app: the document viewer, the thinking blocks, the question
bubbles — the renderer's output is byte-identical to pre-phase, so a
document that legitimately contains a ` ```bor ` fence still renders its
own text. Pass order with the option on:
**verbatim → fences → tables → escape+inline → blockquotes → paragraphs →
restore**.
- **Hydration paints the panel, and the panel is the file.** The client half
is one classic script, `frontend/assets/verbatim.js`, loaded after
`markdown.js` on the chat page and the shared page. At `done` (and on every
restore) it matches each placeholder's normalized `data-bor-locator`
against the frame's `answer_text[].locator` with a plain string compare —
`markdown.js::borLocator` stays the ONLY locator grammar in the client, so
the two sides cannot drift — and paints:
- `.verbatim-caption` — `source/path · lines 12–40 of 210`, or
`source/path · all 210 lines` when the block is the whole document: the
numbers are the **server's** extent, so a locator whose range the model got
wrong still shows what was actually read (a panel matched on `source/path`
alone is A2 made visible).
- `.verbatim-badge` — the word `VERBATIM`: these bytes came from the
knowledge base, not from the model.
- `.verbatim-lines` — the server's line numbers, `user-select: none`, so a
selection or a Copy never drags digits into the clipboard. A capped block's
marker line gets a BLANK cell, because the marker is not a line of the file.
- `.verbatim-text` — `answer_text[].text` through `textContent`, byte for
byte (never re-wrapped, never trimmed, never joined).
- `.verbatim-copy` — `Copy` → `Copied` (the state is TEXT), accessible name
`Copy N lines from <source/path>`; the clipboard receives the server string
exactly.
- `.verbatim-open` — the document viewer: on the chat page the same same-page
modal as a source chip (back → the chat), on the shared page a plain
`/document.html?source=…&path=…` link.
- `.verbatim-more` — on a capped block, one muted line saying the rest is in
the document, plus that link.
A locator the server did not resolve renders the honest sentence *I could
not verify this against the knowledge base — open `<source/path>`* with NO
badge, NO body and none of the model's bytes; and every terminal path (the
`error` frame, the 300 s guard, Stop, the failed-turn funnel) settles any
leftover placeholder, so nothing ever sits at *Retrieving the exact text…*
once a turn is over. A `show_text` block the answer never located is
appended in the `answer_image` band — below the answer text, above the
source clusters.
- **Every settled answer code block can be copied.** `enhanceAnswerCodeBlocks`
wraps each `<pre>` inside a settled answer bubble that is not part of a
panel in `.code-block` and adds a `.code-block-copy` pill: **no badge and no
caption**, because a `VERBATIM` claim belongs only to a server-resolved
block — an answer's code is either verbatim-and-badged or plainly the
model's, never a silently-substituted hybrid. Buttons appear when the turn
settles, never mid-stream. The share link's copy path delegates to the same
`copyTextWithFallback`, so the http-origin inline select-to-copy degradation
exists once (the owner-locked share strings and behavior are unchanged).
- **Parity, and what stays untouched.** The live turn, a reload and an
anonymous shared page call the same three globals in the same order, so one
answer renders one way. Only the two answer renderers (plus the shared
page's) opt in: a question, a thinking run and the document viewer stay
one-argument calls, and `document.html` does not even load the panel client.
---
## PDF documents (phase 126)
PDFs are **opt-in**: add the `pdf` token to `BOR_IMPORT_EXTENSIONS`
@@ -1339,6 +1505,9 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
|-----|---------|---------|
| `BOR_AGENT_MAX_ROUNDS` | `10` | Hard cap on agent tool rounds per grounded turn (0 = no tools); every tool result carries the model's remaining count (phase 160) |
| `BOR_READ_MAX_CHARS` | `128000` | Cap on a `read` tool result (chars); over-cap reads truncate + a notice that teaches the grep → `read(offset, limit)` workflow (phase 95, rewritten phase 131) |
| `BOR_ANSWER_TEXT` | `1` | Verbatim file display (phase 182): the **master switch, ON by default** — an answer's code block is typed by the server from the indexed bytes, never retyped by the model. `0` = the escape hatch: the `show_text` tool is not offered, its `<tools>` teaching clause is absent, ` ```bor ` locator fences are not resolved, and the `done` frame carries no `answer_text` key (the server surface is exactly pre-phase; a stray fence on the client still settles to the honest unresolved sentence rather than the model's copy) |
| `BOR_SHOW_TEXT_MAX_CHARS` | `12000` | Per-block character budget for ONE displayed verbatim slice (phase 182): the slice is cut at a **line boundary**, the shared `[…truncated…]` marker is appended and the caption's line range shrinks to what is actually shown. `0` = inert (nothing displays); a negative value refuses startup naming the field |
| `BOR_SHOW_TEXT_MAX_BLOCKS` | `5` | Per-answer cap on displayed verbatim blocks (phase 182), counted in answer order. `0` = inert (nothing displays); a negative value refuses startup naming the field |
### Import & sources
@@ -1364,7 +1533,7 @@ startup (the DB rows shadow the env, `.env` is never rewritten).
| `BOR_IMAGE_DIR` | `~/bor-sources/images` | Persistent home for the served image bytes (uploads are replaced, checkouts re-cloned) |
| `BOR_CHAT_IMAGE_DIR` | `~/bor-sources/chat-images` | Where question images are stored (saved/shared chats carry the path, never base64) |
| `BOR_CHAT_IMAGE_MAX_MB` | `10` | Question-image upload cap (MiB; must be > 0) |
| `BOR_ANSWER_IMAGES` | `0` | Let the chat model show a standalone image document as part of its answer (phase 124): offers the agent the `show_image` tool and renders the shown image as an answer figure — **independent of `BOR_IMAGES`** (that toggle indexes image docs; this one shows them) |
| `BOR_ANSWER_IMAGES` | `0` | Let the chat model show a standalone image document as part of its answer (phase 124): offers the agent the `show_image` tool and renders the shown image as an answer figure — **independent of `BOR_IMAGES`** (that toggle indexes image docs; this one shows them). The text-display sibling (`BOR_ANSWER_TEXT`, phase 182) lives in [Agent tools](#agent-tools) |
### Docs push
+5 -5
View File
@@ -244,16 +244,16 @@ def _refuse_embedded_credentials(key: str, value: str) -> None:
def get_catalogue(
db: Session = Depends(get_db), # noqa: B008
) -> AdminSettingsOut:
"""The whole catalogue (78 items, ``GROUPS`` order — the stable
order the E2E pins against).
"""The whole catalogue (one item per ``Settings`` field, ``GROUPS``
order — the stable order the E2E pins against).
Reads the table once, resolves the effective ``Settings`` through
``get_settings()`` (the overlay — DB-over-env, task 02), and
emits one item per field. GETs log nothing here: the phase-167
``ResponseBudgetMiddleware`` already writes the
``api method=… bytes=…`` line, and the whole catalogue must stay
under ``BOR_LOG_LARGE_RESPONSE_BYTES``' INFO bar (78 items ≈ 20 KB
— verified in task 06 with the audit script).
under ``BOR_LOG_LARGE_RESPONSE_BYTES``' INFO bar (the whole catalogue
is ≈ 20 KB — verified in task 06 with the audit script).
"""
rows = _rows(db)
effective = get_settings()
@@ -371,7 +371,7 @@ def reset_value(
@router.delete("", response_model=AdminSettingsCleared)
def reset_all(db: Session = Depends(get_db)) -> AdminSettingsCleared: # noqa: B008
"""The panel's "Clear all overrides": delete every row (the env
values win again for all 78 settings), invalidate (task 03) and
values win again for every setting), invalidate (task 03) and
report how many rows went."""
result = db.execute(delete(AppSetting).returning(AppSetting.key))
db.commit()
+97 -2
View File
@@ -227,6 +227,30 @@ pre-phase. A question image is turn-local: it is NEVER indexed as a
document, and prior turns' images are never replayed into the model's
history (``history_to_messages`` is unchanged — the text of a prior
turn that had an image stands alone, LOCKED A7).
Verbatim display (phase 182, tasks 03/05; LOCKED A1/A4/A7/A9): a turn
that showed file contents through the ``show_text`` tool (task 03, the
bytes the SERVER resolved) — or whose ANSWER carries a `` ```bor ``
locator fence (task 02's resolver, placement sugar that resolves
standalone) — gains one additive ``done`` key, ``answer_text``: the
list of :class:`~app.schemas.VerbatimBlockRef` blocks in DISPLAY order,
each carrying the exact ``documents.content`` slice plus the SERVER's
resolved 1-based, inclusive line extent and its canonical ``locator``.
The model never supplies those bytes: the endpoint accumulates the
turn's CLEAN VISIBLE answer text (the same post-scaffold-filter pieces
that feed ``content_chars`` — never ``thinking``, so a locator can only
ever come from the answer) and resolves it through
:func:`app.rag.verbatim.resolve_answer_blocks` when the ``done`` frame
is built. A deflected turn resolves NOTHING (D9 — a fence hallucinated
into a disclaimer must not leak KB text through the turn that just said
it had nothing); ``answer_text=False``, ``show_text_max_blocks=0`` and
``show_text_max_chars=0`` short-circuit inside the resolver, and a turn
with no blocks serializes WITHOUT the key (A9 — every block-free frame
keeps its exact pre-phase bytes). The per-turn log line records
``verbatim_blocks=N`` after ``scaffold_stripped=N`` — the blocks
ACTUALLY SENT (after dedupe and the ``show_text_max_blocks`` cut), 0 on
every turn without them including deflected ones (the PLAN §9 additive
revision).
"""
from __future__ import annotations
@@ -235,7 +259,7 @@ import json
import logging
import time
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
@@ -283,6 +307,9 @@ from app.rag.summarizer import (
IMAGE_MIMES,
image_data_url,
) # phase 123: the phase-122 mime map + the shared data-URL helper
from app.rag.verbatim import (
resolve_answer_blocks, # phase 182: fences + shown blocks -> the done frame
)
from app.schemas import (
ChatDoneEvent,
ChatErrorEvent,
@@ -292,6 +319,7 @@ from app.schemas import (
ChatToolEvent,
ChatToolResultEvent,
SourceRef,
VerbatimBlockRef,
)
logger = logging.getLogger("app.chat")
@@ -527,6 +555,12 @@ def plan_turn(
# carries the show_image clause exactly when the
# BOR_ANSWER_IMAGES toggle is on (off = byte-identical).
show_image=settings.answer_images,
# Phase 182 (task 04, LOCKED A1/A8): the same rule for the
# show_text clause — it rides exactly when the BOR_ANSWER_TEXT
# master switch is on (the default), and an off build is
# byte-identical to pre-phase (no clause, and agent_tools
# offers four tools, not five).
show_text=settings.answer_text,
),
suggested,
related_docs,
@@ -933,6 +967,16 @@ async def chat(
thinking_chars = 0
content_chars = 0 # phase 71: the turn's visible (clean) content
scaffold_stripped = 0 # phase 71: sum across the turn's requests
# Phase 182 (task 05, D3): the turn's clean VISIBLE answer
# text — the SAME pieces counted into ``content_chars``
# (post-scaffold-filter, never ``thinking``, so a locator
# can only ever come from the answer), kept so the ``done``
# builder can resolve the `` ```bor `` fences the answer
# wrote against the indexed rows. Bounded by the model's own
# output-token budget (``max_output_tokens``) — the answer
# already lives in memory for the duration of the turn, so
# this adds no new cap.
visible_parts: list[str] = []
# Phase 160 (task 02): the transport half of the cap path — a
# request made WITHOUT tools (the deflected turn, A8, and its
# recovery) never produces a ``tool`` frame: a structured
@@ -1082,6 +1126,9 @@ async def chat(
yield sse_event(ChatThinkingEvent(text=piece.text).model_dump())
else:
content_chars += len(piece.text)
# Phase 182 (task 05): the same clean piece kept
# as TEXT for the done-time locator pass.
visible_parts.append(piece.text)
yield sse_event({"type": "delta", "text": piece.text})
if dropped_calls:
logger.debug(
@@ -1269,12 +1316,46 @@ async def chat(
except Exception: # noqa: BLE001 — the answer already went out
logger.exception("chat: failed to write query_log question=%r", request.message)
# Phase 182 (task 05, D2/D9): resolve the turn's verbatim
# display blocks — the ``show_text`` tool's blocks
# (``holder.shown_texts``, read AFTER the stream exactly
# like ``shown_images``) fused with the blocks the answer's
# own `` ```bor `` locator fences name, over the CLEAN
# visible text accumulated above (never ``thinking``).
# A DEFLECTED turn resolves NOTHING (D9 — a fence
# hallucinated into a disclaimer must not leak KB text
# through the turn that just said it had nothing), which is
# also why this is the only branch with a resolved holder.
# A DB failure here must not break a stream whose answer
# already went out (the query_log precedent above): the
# turn degrades to NO blocks, the frame simply omits the
# key, and the client renders each fence in its honest
# unresolved state — never the model's own bytes.
blocks: list[VerbatimBlockRef] = []
if not plan.deflected:
try:
with SessionLocal() as verbatim_db:
resolved = resolve_answer_blocks(
verbatim_db,
"".join(visible_parts),
holder.shown_texts,
settings,
)
blocks = [VerbatimBlockRef(**asdict(b)) for b in resolved]
except Exception: # noqa: BLE001 — the answer went out
logger.exception(
"chat: verbatim block resolution failed question=%r",
request.message,
)
blocks = []
logger.info(
"question=%r embed_ms=%d top_score=%.3f fts_hits=%d summary_hits=%d "
"suggested=%d tuning=%d "
"kb_chars=%d history_msgs=%d threshold=%.2f deflected=%s sources=%r "
"thinking=%s thinking_chars=%d tool_calls=%d rounds=%d "
"cap_hit=%d total_ms=%d retries=%d scaffold_stripped=%d",
"cap_hit=%d total_ms=%d retries=%d scaffold_stripped=%d "
"verbatim_blocks=%d",
request.message,
embed_ms,
plan.top_score,
@@ -1299,6 +1380,12 @@ async def chat(
total_ms,
retries_used,
scaffold_stripped,
# phase 182 (task 05): the blocks ACTUALLY SENT (after
# the canonical-locator dedupe and the
# ``show_text_max_blocks`` cut) — 0 on every turn
# without them, deflected ones included (PLAN §9
# additive revision).
len(blocks),
)
settled = True # terminal: the done frame settles the turn
# Phase 112 (A8 revised, TODO L2): a deflected turn cites
@@ -1360,6 +1447,14 @@ async def chat(
# (``False`` → ABSENT on serialization) is
# correct by construction.
budget_exhausted=holder.cap_reached,
# Phase 182 (task 05): the answer's verbatim display
# blocks, resolved above in DISPLAY order (answer
# order first, then the tool blocks the answer never
# located). ``blocks or None`` — an empty list is
# ``None`` here and the serializer drops the key
# either way, so a block-free frame keeps its exact
# pre-phase bytes (LOCKED A9).
answer_text=blocks or None,
).model_dump()
)
finally:
+57
View File
@@ -294,6 +294,41 @@ class Settings(BaseSettings):
#: 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
#: Feature toggle for the verbatim text-display capability (phase 182,
#: ``BOR_ANSWER_TEXT``; ``0``/``false`` = off). The single knob that
#: turns the whole feature on or off. Gates the ANSWER side ONLY: while
#: on, the grounded agent is offered the ``show_text`` display tool,
#: the HIGH prompt carries its teaching clause, a `` ```bor `` locator
#: fence in the answer is resolved server-side, and the resolved blocks
#: ride the done frame's ``answer_text`` key (and the saved record's).
#: While off, behavior is byte-identical everywhere — no ``show_text``
#: tool offered, no prompt clause, no locator resolution, no
#: ``answer_text`` key in any frame or record. THE DEFAULT IS **TRUE**
#: (owner decision 2026-10-14, LOCKED A8): unlike phase 124's
#: ``answer_images`` (default off, a cosmetic extra), an answer whose
#: code block silently differs from the file is a correctness bug, so
#: the fix ships ON and this switch is an escape hatch, not an opt-in.
answer_text: bool = True
#: Cap in characters on ONE displayed verbatim slice (phase 182,
#: ``BOR_SHOW_TEXT_MAX_CHARS``): the slice the server resolves for a
#: ``show_text`` call or a `` ```bor `` locator fence is cut at the cap
#: AT A LINE BOUNDARY, the shared ``[…truncated…]`` marker
#: (``app/rag/retriever.py``) is appended and the caption's ``end_line``
#: shrinks to the last fully-shown line — the caption never lies (D4).
#: Worst-case ``done``-frame growth is
#: ``show_text_max_chars * show_text_max_blocks`` = 60 KB against the
#: 256 KB response budget (phase 167's byte line makes it visible). A
#: char cap like ``read_max_chars`` (no tokenizer in the repo); ``0``
#: is inert (nothing displays), a negative value is a typo and fails
#: startup (validator).
show_text_max_chars: int = 12_000
#: Cap on the verbatim blocks displayed per answer (phase 182,
#: ``BOR_SHOW_TEXT_MAX_BLOCKS``): the ``show_text`` calls and
#: `` ```bor `` locator fences of one turn, in ANSWER order, past this
#: count are not displayed (D8). ``0`` is inert (no block displays —
#: the feature renders as plain prose), a negative value is a typo and
#: fails startup (the ``agent_max_rounds`` validator pattern).
show_text_max_blocks: int = 5
# --- Chat concurrency (SEC-14-04, task 03) ---
#: Maximum concurrent chat turns allowed (SEC-14-04).
@@ -705,6 +740,28 @@ class Settings(BaseSettings):
raise ValueError("read_max_chars must be >= 0 (chars)")
return v
@field_validator("show_text_max_chars")
@classmethod
def _show_text_max_chars_non_negative(cls, v: int) -> int:
"""A negative per-block cap is a typo — it would slice from the END
of the content (negative indexing) instead of failing. Fail loud at
startup, naming the field (the ``read_max_chars`` pattern, phase
182). ``0`` is legal and inert (no block displays)."""
if v < 0:
raise ValueError("show_text_max_chars must be >= 0 (chars)")
return v
@field_validator("show_text_max_blocks")
@classmethod
def _show_text_max_blocks_non_negative(cls, v: int) -> int:
"""A negative per-answer block cap is a typo — it would count down
from the END of the block list instead of failing. Fail loud at
startup, naming the field (the ``agent_max_rounds`` pattern, phase
182). ``0`` is legal and inert (no block displays)."""
if v < 0:
raise ValueError("show_text_max_blocks must be >= 0 (blocks)")
return v
@field_validator("pdf_pictorial_min_chars")
@classmethod
def _pdf_pictorial_min_chars_non_negative(cls, v: int) -> int:
+7
View File
@@ -267,6 +267,13 @@ CATALOG: dict[str, SettingMeta] = {
"read_max_chars": SettingMeta(
"read_max_chars", "Agent tools", "Read max chars"
),
"answer_text": SettingMeta("answer_text", "Agent tools", "Answer text"),
"show_text_max_chars": SettingMeta(
"show_text_max_chars", "Agent tools", "Show text max chars"
),
"show_text_max_blocks": SettingMeta(
"show_text_max_blocks", "Agent tools", "Show text max blocks"
),
# --- Import & sources ---
"import_extensions": SettingMeta(
"import_extensions", "Import & sources", "Import extensions (CSV)"
+292 -13
View File
@@ -211,6 +211,29 @@ task 04):
— its ``"No matches for …"`` line is a (counted) result. A rejected
call still consumes a *round* in the loop, so a pathological stream
that keeps emitting rejected calls is bounded by the cap (point 4).
Display tools (phase 124 ``show_image``, phase 182 ``show_text`` —
both composed by :func:`agent_tools`, each behind its own flag): they
display content FOR THE USER. ``show_image`` never adds context; the
5th tool ``show_text`` (phase 182, task 03) returns the resolved
slice back to the model ONLY as the truth of what was displayed
(LOCKED A3 — with a do-NOT-retype instruction: the model describes
the block, the user copies the server's bytes), and its block rides
``holder.shown_texts``, never ``read_docs``. The LOCKED core contract
A1–A3/A6: **the model names a location, the server shows the bytes**
— the handler resolves the slice through
:func:`app.rag.verbatim.block_for` (same ``(source, path)`` identity,
same 1-based ``splitlines()`` indexing as ``read``/``grep``, same
char-budget-cut-at-a-line-boundary), the DISPLAYED EXTENT is the
server's (an overshooting ``limit`` clamps into the result's ``lines:``
header, an ``offset`` past the end is the :data:`OFFSET_PAST_END`
teaching naming the true line count, the char cap shrinks the extent
— the same strings ``read`` uses, one copy each), images belong to
``show_image`` (a show of an ``is_image`` row is the
:data:`SHOW_TEXT_NOT_A_TEXT_DOC` refusal, the exact mirror of
:data:`NOT_AN_IMAGE_DOC` — never a silent conversion), and the inline
``` ```bor ``` locator-fence form of the same contract (phase 182 D2)
is resolved at the API layer (task 05), NOT here.
4. Every call the model emits is appended back to the message history as
the assistant tool-call message + the tool result (refusals included),
consumes one round, and the model is called again. Every tool result
@@ -317,6 +340,7 @@ from app.rag.llm import (
from app.rag.retriever import TRUNCATION_MARKER, _fold
from app.rag.scaffolding import ScaffoldingFilter
from app.rag.source_removal import resolve_source_name
from app.rag.verbatim import VerbatimBlock, block_for
logger = logging.getLogger("app.agent")
@@ -535,20 +559,104 @@ SHOW_IMAGE_TOOL: dict[str, Any] = {
}
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
"""The grounded turn's tool offer list (phase 124, task 02).
#: The 5th agent tool (phase 182, task 03; LOCKED A1–A3/A6) — offered
#: ONLY when ``settings.answer_text`` (``BOR_ANSWER_TEXT``, default ON):
#: :func:`agent_tools` composes the offer list (the three base tools stay
#: :data:`AGENT_TOOLS`, byte-identical). The description IS the behavior
#: contract (the phase-124 discipline): the model NAMES a location, the
#: SERVER shows the bytes verbatim — the user copies what is displayed,
#: so the model must NOT retype it. Same OpenAI function shape as the
#: other tools: one required string ``path`` (the combined
#: ``source/path`` identity — the bare-path-will-not-resolve teaching
#: mirrored from ``read``) plus the OPTIONAL ``offset``/``limit`` line
#: range (the ``read`` wording reused, so every line-number surface in
#: the app teaches one grammar).
SHOW_TEXT_TOOL: dict[str, Any] = {
"type": "function",
"function": {
"name": "show_text",
"description": (
"Display part of a document to the user EXACTLY as stored "
"— the user sees the server's bytes and can copy them. "
"Call it when the user wants to see, copy or run a file (a "
"quadlet, a config, a script): they copy from what is "
"displayed, so do NOT retype the content yourself after "
"calling it — describe it instead. `path` is the combined "
"`source/path` string exactly as shown in `ls`. `offset` "
"(1-based first line) and `limit` (line count) are "
"optional — omit them to display the whole document; use "
"them when `grep`/`read` told you which lines matter. The "
"displayed line numbers come from the server, not from "
"your arguments: an overshooting `limit` is clamped and an "
"`offset` past the end is refused with the true line "
"count. Only text documents — for an image use "
"`show_image`. Call one tool at a time — wait for this "
"result before your next call."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": (
"The document to display, as the combined "
"`source/path` string exactly as shown in the "
"`ls` output (e.g. "
"'homelab/active/container_caddy/caddy.md'). "
"A bare document path (without the source "
"name) will not resolve. Only text documents "
"— for an image use `show_image`."
),
},
"offset": {
"type": "integer",
"description": (
"The first line to display, 1-based. Omit it "
"to display from the beginning of the "
"document. Use it to display a specific part "
"of a large document — grep reports the line "
"numbers of what you found."
),
},
"limit": {
"type": "integer",
"description": (
"The maximum number of lines to display, "
"starting at offset. Omit it to display as "
"many lines as fit under the display cap. Use "
"it to show the part the user asked about — "
"you are showing a part, not the document."
),
},
},
"required": ["path"],
},
},
}
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus
def agent_tools(settings: Settings) -> list[dict[str, Any]]:
"""The grounded turn's tool offer list (phase 124, task 02; the 5th
tool, phase 182, task 03).
The three base tools (:data:`AGENT_TOOLS`, byte-identical) plus the
TWO display tools, each gated by its own flag:
:data:`SHOW_IMAGE_TOOL` when ``settings.answer_images``
(``BOR_ANSWER_IMAGES``) is true — otherwise exactly the three base
tools (the pre-phase-124 offer, byte-identical: no 4th tool in the
wire ``tools`` array). A FRESH list on every call — callers never
mutate the result or the constants (``run_agent`` passes it straight
to the LLM request).
(``BOR_ANSWER_IMAGES``) and :data:`SHOW_TEXT_TOOL` when
``settings.answer_text`` (``BOR_ANSWER_TEXT``) — the offer order is
``ls``, ``read``, ``grep``, ``show_image``, ``show_text``. With both
flags off: exactly the three base tools (the pre-phase-124 offer,
byte-identical: no display tool in the wire ``tools`` array); with
only ``answer_text`` off it is the pre-phase-182 offer
byte-identical. A FRESH list on every call — callers never mutate
the result or the constants (``run_agent`` passes it straight to the
LLM request).
"""
tools: list[dict[str, Any]] = list(AGENT_TOOLS)
if settings.answer_images:
tools.append(SHOW_IMAGE_TOOL)
if settings.answer_text:
tools.append(SHOW_TEXT_TOOL)
return tools
#: Tool refusal texts (phase 37): rejected calls count in nothing
@@ -587,6 +695,56 @@ ALREADY_SHOWING = (
"Answer from what you have."
)
#: The ``show_text`` refusals (phase 182, task 03): the same contract as
#: every other refusal — counts in nothing, consumes a round, changes no
#: context. ``MISSING_SHOW_TEXT_ARGS`` mirrors ``MISSING_SHOW_ARGS``.
#: ``SHOW_TEXT_NOT_A_TEXT_DOC``: one ``{arg}`` field, the argument echoed
#: as passed — the row is a standalone IMAGE; image display belongs to
#: ``show_image`` (LOCKED A6 — the exact mirror of
#: :data:`NOT_AN_IMAGE_DOC`, never a silent conversion). The range
#: problems REUSE :data:`BAD_RANGE_ARGS` and :data:`OFFSET_PAST_END`
#: verbatim — the same teaching, the same echoed values, one copy of
#: each string in the codebase.
MISSING_SHOW_TEXT_ARGS = "show_text requires a string argument 'path'."
SHOW_TEXT_NOT_A_TEXT_DOC = (
"'{arg}' is a standalone image document — use show_image to display "
"images."
)
#: One ``{max}`` field (``settings.show_text_max_blocks``): the
#: per-answer block budget, enforced SERVER-side BEFORE the resolve work
#: (the model is taught the budget in the description; the handler is
#: the actual gate). ``0`` therefore disables the tool's effect while
#: keeping it offered (the house kill-switch pattern).
SHOW_TEXT_BLOCK_CAP = (
"{max} text blocks are already displayed this answer — describe them "
"instead of showing more."
)
#: One ``{locator}`` field — the canonical ``source/path[#first-last]``
#: the model just asked for: the dedupe mirror of
#: :data:`ALREADY_SHOWING`, but per BLOCK under the block budget (the
#: budget allows SEVERAL different blocks; the same block twice teaches).
SHOW_TEXT_ALREADY_SHOWN = (
"{locator} is already on screen this answer — say so instead of "
"showing it again."
)
#: The ``show_text`` truncation notice (phase 182, task 03) — appended as
#: the result's LAST line when the per-block char budget
#: (``settings.show_text_max_chars``) cut the displayed slice (the block
#: text already ends with the shared :data:`TRUNCATION_MARKER` on its own
#: line, and the ``lines:`` extent already shrank to the last line
#: actually displayed). In the :data:`READ_TRUNCATION_NOTICE` spirit it
#: names the truth the model must not paper over — the rest is NOT
#: displayed — and the way the user gets it (open the document; NO
#: ``offset``/``limit`` re-show loop is implied, only the displayed
#: slice is what the user copies). Fields: ``{source}``/``{path}``, the
#: RESOLVED identity, plain (no ``!r``).
SHOWN_TEXT_TRUNCATION_NOTICE = (
"TRUNCATED — the rest of {source}/{path} is NOT displayed. The user "
"can open the document to read it in full; describe the displayed "
"lines instead of showing it again."
)
#: The read-truncation notice (phase 95, task 01; REWRITTEN phase 131,
#: task 01) — appended, after the shared :data:`TRUNCATION_MARKER`, to
#: a ``read`` result whose document is LONGER than
@@ -1642,6 +1800,17 @@ class AgentHolder:
nothing). Read by ``app.api.chat`` after the stream for the done
frame's ``answer_image`` field (task 04) — the ``read_docs``
precedent (an API-layer read-after-stream signal).
``shown_texts`` (phase 182, task 03): the verbatim blocks
``show_text`` displayed, in CALL order, capped by
``settings.show_text_max_blocks`` (a call over the budget is refused
with :data:`SHOW_TEXT_BLOCK_CAP` and a duplicate of a shown locator
with :data:`SHOW_TEXT_ALREADY_SHOWN` — both append nothing). Each
entry is the RESOLVED
:class:`app.rag.verbatim.VerbatimBlock` — the server's bytes plus
the server's extent (the canonical ``locator`` is the dedupe key and
the client's matching key, D7). Read by ``app.api.chat`` after the
stream for the done frame's ``answer_text`` array (task 05) — the
``shown_images``/``read_docs`` read-after-stream precedent.
``read_coverage`` (phase 141, task 01): one ``(source, path,
first_line, last_line)`` record per covered span — INCLUSIVE line
numbers over the document's ``content.splitlines()``, in read
@@ -1676,6 +1845,7 @@ class AgentHolder:
read_truncations: list[tuple[str, int, int]] = field(default_factory=list)
shown_images: list[Document] = field(default_factory=list)
read_coverage: list[tuple[str, str, int, int]] = field(default_factory=list)
shown_texts: list[VerbatimBlock] = field(default_factory=list)
def _execute_tool(
@@ -2065,6 +2235,113 @@ def _execute_tool(
f"{IMAGE_DOC_MARKER}\n"
f"{doc.content}"
)
if call.name == "show_text":
# Phase 182 (task 03; LOCKED A1–A3/A6): the 5th agent tool — the
# model NAMES a location, the SERVER shows the bytes verbatim.
# Offered only when ``settings.answer_text`` (``agent_tools``);
# the handler enforces the contract server-side. Every refusal
# below appends nothing, increments nothing and consumes a
# round, like every other refusal.
raw_path = call.arguments.get("path")
arg = raw_path.strip() if isinstance(raw_path, str) else ""
if not arg:
return MISSING_SHOW_TEXT_ARGS
if not settings.answer_text:
# Defensive — the tool is NOT OFFERED while the flag is off,
# so from the model's side this is a tool that does not
# exist: the same refusal an un-offered name gets (the
# tools-off posture — "Unknown tool."), counts in nothing.
return UNKNOWN_TOOL
doc, _source, _path = _resolve_path(db, arg)
if doc is None:
# The same phase-72 "did you mean …?" teaching as the read
# branch (a refusal — not counted, no context, nothing
# displayed).
return _no_document_refusal(db, arg)
if doc.is_image:
# LOCKED A6: an image row is refused, NEVER silently
# converted — image display is ``show_image``'s job (the
# mirror of the show_image branch's NOT_AN_IMAGE_DOC).
return SHOW_TEXT_NOT_A_TEXT_DOC.format(arg=arg)
# The OPTIONAL line range — the SAME validator, the SAME echoed
# BAD_RANGE_ARGS teaching as ``read`` (one copy of each string);
# ``offset`` without ``limit`` displays to the end, ``limit``
# without ``offset`` counts from line 1 — ``slice_lines`` applies
# both one-sided semantics with ``read``'s exact indexing.
ok, range_error, offset, limit = _read_range_args(
call.arguments.get("offset"), call.arguments.get("limit")
)
if not ok:
return range_error
# The per-answer block budget — checked BEFORE the resolve work
# (and, with a 0 char budget, before ``cap_slice`` could raise):
# either zeroed kill switch refuses like a spent budget, so
# ``0`` disables the tool's EFFECT while it stays offered (D8).
block_budget = (
settings.show_text_max_blocks
if settings.show_text_max_chars > 0
else 0
)
if len(holder.shown_texts) >= block_budget:
return SHOW_TEXT_BLOCK_CAP.format(max=block_budget)
block = block_for(
db,
arg,
offset=offset,
limit=limit,
cap=settings.show_text_max_chars,
doc=doc,
)
if block is None:
# An empty document or an ``offset`` past the last line —
# the SAME OFFSET_PAST_END teaching as ``read``, the true
# line count named (the same ``len(content.splitlines())``
# expression ``read`` uses). Counts in nothing; a corrected
# retry still proceeds.
return OFFSET_PAST_END.format(
source=doc.source,
path=doc.path,
total_lines=len(doc.content.splitlines()),
offset=offset if offset is not None else 1,
)
if any(shown.locator == block.locator for shown in holder.shown_texts):
# The canonical-locator dedupe (D7): the identical block is
# already on screen — say so instead of painting it twice.
return SHOW_TEXT_ALREADY_SHOWN.format(locator=block.locator)
# Success: the block rides the holder for the done frame
# (task 05) and the call counts (executed-only, the house rule).
holder.shown_texts.append(block)
holder.tool_calls += 1
# The result contract (D5 — the ``read``/``show_image`` header
# contract the E2E mock's ``_READ_RESULT_PREFIX`` parses): a NEW
# first line (absent from ``read`` — the model must know the
# show registered AND must not retype), then the exact document
# headers ``read`` established (date line, phase-145 origin
# lines), then the SERVER's resolved extent — clamped ``limit``
# and cap-shrunk end included, the caption never lies — and the
# verbatim slice itself (A21: LLM calls are free, and returning
# the text means the model's commentary can never disagree with
# the panel).
origin = origin_for_source(db, doc.source)
result = (
"Text shown to the user — these lines are displayed "
"verbatim; describe them, do NOT retype them.\n"
f"Document {doc.source}/{doc.path}:\n"
f"date: {doc.created_at:%Y-%m-%d}\n"
f"{_origin_lines(origin)}"
f"lines: {block.start_line}-{block.end_line} of "
f"{block.total_lines}\n"
f"{block.text}"
)
if block.truncated:
# The char cap cut the slice: the block already ends with
# the shared marker on its own line — append the pinned
# truth (the rest is NOT displayed; the user opens the
# document; no re-show loop implied).
result += "\n" + SHOWN_TEXT_TRUNCATION_NOTICE.format(
source=doc.source, path=doc.path
)
return result
if call.name == "grep":
raw_pattern = call.arguments.get("pattern")
pattern = raw_pattern.strip() if isinstance(raw_pattern, str) else ""
@@ -2229,11 +2506,13 @@ async def run_agent(
# Phase 45: no per-tool budgets — the tools stay offered for the
# whole turn, bounded by the round cap. ``0`` is the no-tools kill
# switch: exactly one request with ``tools=None`` (the pre-phase-37
# path). Phase 124 (task 02): the offer list is :func:`agent_tools`
# — the three base tools (byte-identical) plus ``show_image`` only
# when ``settings.answer_images`` (``BOR_ANSWER_IMAGES``) is on;
# off (the default) the offer is element-for-element the
# pre-phase-124 list.
# path). Phase 124 (task 02) / 182 (task 03): the offer list is
# :func:`agent_tools` — the three base tools (byte-identical) plus
# ``show_image`` only when ``settings.answer_images``
# (``BOR_ANSWER_IMAGES``) and ``show_text`` only when
# ``settings.answer_text`` (``BOR_ANSWER_TEXT``, default ON); both
# off: the offer is element-for-element the pre-phase-124 list,
# ``answer_text`` alone off: the pre-phase-182 list.
max_rounds = settings.agent_max_rounds
tools: list[dict[str, Any]] | None = (
agent_tools(settings) if max_rounds > 0 else None
+105 -7
View File
@@ -66,6 +66,20 @@ backstop). The ``DEFLECT_MODE`` marker and everything else in the
prompt stay put — the E2E mock LLM keys on the marker's *presence*,
not the wording, so that contract is unchanged.
Display-tool clauses (phase 124 ``show_image``; phase 182 ``show_text``):
the **HIGH** prompt's ``<tools>`` section can carry two extra clauses
before its closing tag, each appended only when its own setting is on —
:data:`SHOW_IMAGE_CLAUSE` when ``BOR_ANSWER_IMAGES`` (default OFF) and
:data:`SHOW_TEXT_CLAUSE` when ``BOR_ANSWER_TEXT`` (default ON). The text
clause teaches the two things the phase-182 incident needed said: the
user copies from what is DISPLAYED (so never retype a file), and the
inline `` ```bor <source/path> [range] ``` `` locator fence whose EMPTY
body the app replaces with the server-resolved bytes. Both clauses
compose through one mechanism (section minus its final tag + the present
clauses + the tag), so a build with neither clause is byte-identical to
pre-phase and a ``show_image``-only build reproduces the phase-124 bytes
exactly; the LOW prompt carries neither.
Disclosed-answer contract (phase 112, task 03; owner decision
2026-09-14, roadmap confirmation — option (iii) of the three the
2026-09-15 interactive deflection test raised): that test found the
@@ -388,6 +402,61 @@ SHOW_IMAGE_CLAUSE: str = (
"in text either way."
)
#: The ``show_text`` contract clause (phase 182, task 04, LOCKED A1–A3/A6) —
#: appended INSIDE the ``<tools>`` section of the HIGH prompt, after the
#: existing three-tool copy (and after :data:`SHOW_IMAGE_CLAUSE` when that
#: rides too), ONLY when :func:`build_high_prompt` is called with
#: ``show_text=True`` (the ``BOR_ANSWER_TEXT`` master switch, via
#: ``app.api.chat``'s grounded plan — the only call site that passes it).
#: It exists because the incident this phase fixes is a PROMPT gap, not a
#: tool gap: nothing ever told the model that the user copies from what is
#: DISPLAYED, so it retyped files out of context and every retype differed
#: from the stored bytes by a character. What each sentence pins, in order:
#: (1) the display contract — the tool shows the document EXACTLY as stored
#: and the copy source is the display, not the prose; (2) when to call it —
#: the user wants to SEE, COPY or RUN a file (the owner's quadlet / config /
#: script shapes) rather than read content out of the answer; (3) the do-
#: NOT-retype rule with its reason (anything the model types may differ by
#: one character and the user cannot tell — the phase's reason for
#: existing); (4) the placement syntax — the inline `` ```bor <source/path>
#: [range] ``` `` locator fence with an EMPTY body (D1/D7: a fence is a
#: resolution request, the app supplies the text, and D3 keeps the model's
#: body out of the DOM); (5) the honesty-about-numbers rule — line numbers
#: come from ``grep``/``read``, and when unsure the range is OMITTED
#: because the displayed extent is the server's (A2); (6) the failure rule
#: — refuse out loud, never reconstruct from memory (the persona's radical
#: honesty, D3's posture on the model side); (7) the image pointer
#: (LOCKED A6: images belong to ``show_image``, never silently converted).
#: ``TOOLS_SECTION`` itself stays BYTE-IDENTICAL (the sha pins in
#: :mod:`tests.unit.test_prompt_lock` hold); a ``show_text=False`` build
#: (the default — every pre-phase call site) is byte-identical to the
#: pre-task output, and a ``show_image=True, show_text=False`` build
#: reproduces the phase-124 bytes EXACTLY. The ``<tools>`` marker the E2E
#: mock keys on is untouched either way — both clauses ride on its
#: presence, not its wording. The LOW/deflection prompt never carries the
#: clause: a deflected turn has no tools, and phase 182's D9 forbids
#: resolving a locator on a turn that just disclaimed the knowledge base.
SHOW_TEXT_CLAUSE: str = (
"`show_text` displays part of a document to the user EXACTLY as stored "
"— the user copies from what is displayed, not from your prose. Call "
"it whenever the user wants to SEE, COPY or RUN a file — a quadlet, a "
"config, a script, a unit file — instead of writing the content into "
"your answer yourself. After calling it, do NOT repeat the displayed "
"lines in your answer — describe them, explain them, point at them. "
"Anything you type yourself may differ from the file by a character, "
"and the user cannot tell. To put a displayed block exactly where you "
"want it in your answer, write a locator line on its own: three "
"backticks followed by `bor`, a space, the combined `source/path`, and "
"optionally the line range (` 12-40`). Leave its body EMPTY — the app "
"replaces it with the exact stored text. Never type the file's content "
"inside a `bor` block. Never guess line numbers — use the numbers "
"`grep` and `read` reported. When you are not sure which lines, OMIT "
"the range and display the whole document: the numbers you display are "
"the server's, never yours. If `show_text` refuses, say what you could "
"not display — do not reconstruct it from memory. For an image use "
"`show_image` instead."
)
def history_to_messages(
history: Sequence[HistoryTurn],
@@ -762,6 +831,7 @@ def build_high_prompt(
kb_overview: str | None = None,
folder_lines: Sequence[str] = (),
show_image: bool = False,
show_text: bool = False,
) -> str:
"""Grounded turn: locked persona (+ steering, + KB overview) + the
top-ranked documents' SUMMARY blocks + the ``<tools>`` instructions
@@ -771,7 +841,8 @@ def build_high_prompt(
seeds SUMMARIES, never full texts — full text enters the context
only through the capped ``read`` tool; phase 119, LOCKED A4: the
suggested-folder context lines, below; phase 124, task 03, LOCKED
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below).
A5: the optional :data:`SHOW_IMAGE_CLAUSE`, below; phase 182, task 04,
LOCKED A1–A3: the optional :data:`SHOW_TEXT_CLAUSE`, below).
Section order: ``<relevance>`` → ``<knowledge_base>`` → ``<tuning>``
→ ``<documents>`` → ``<tools>``; empty steering/overview omit their
@@ -796,6 +867,22 @@ def build_high_prompt(
never carries the clause — a deflected turn has no tools and never
shows an image.
*show_text* (phase 182, task 04, LOCKED A1–A3): when true (the caller
passes the ``BOR_ANSWER_TEXT`` master switch — the same single grounded
call site), the :data:`SHOW_TEXT_CLAUSE` rides INSIDE the same
``<tools>`` section, AFTER :data:`SHOW_IMAGE_CLAUSE` when that is
present too and always before the closing tag. The two flags compose
independently through one mechanism (the built section is
``TOOLS_SECTION`` minus its final ``</tools>`` + the present clauses,
blank-line separated, + the closing tag): ``show_image`` only
reproduces the phase-124 bytes exactly, ``show_text`` only is the
three-tool copy plus the text clause, both is the image clause then
the text clause, neither is byte-identical to pre-phase. The default
``False`` keeps every pre-existing call byte-identical. The deflection
prompt NEVER carries it — a deflected turn has no tools, and phase
182's D9 forbids resolving a ``bor`` locator on a turn whose answer
just disclaimed the knowledge base.
When at least one block is present, the section leads with the
start-here :data:`SUGGEST_INTRO` line (before the first block — the
phase-15 ``_STEERING_INTRO`` / phase-31 ``_KB_INTRO`` precedent): the
@@ -861,13 +948,24 @@ def build_high_prompt(
for part in (build_kb_section(kb_overview or ""), build_steering_section(notes or [])):
if part:
prompt += "\n" + part
# Phase 124 (task 03, LOCKED A5) / phase 182 (task 04, LOCKED A1): the
# display-tool clauses ride inside <tools> — TOOLS_SECTION's text minus
# its final </tools>, the clauses offered for this build (each on its
# own line, blank-line separated, after the existing three-tool copy),
# then the closing tag. TOOLS_SECTION itself is unedited, the
# no-clause path keeps the exact pre-task concatenation, and a
# show_image-only build reproduces the phase-124 bytes exactly.
clauses: list[str] = []
if show_image:
# Phase 124 (task 03, LOCKED A5): the clause rides inside
# <tools> — TOOLS_SECTION's text minus its final </tools>, the
# clause (on its own line, after the existing three-tool copy),
# then the closing tag. TOOLS_SECTION itself is unedited and the
# show_image=False path keeps the exact pre-task concatenation.
tools_section = TOOLS_SECTION[: -len("</tools>")] + SHOW_IMAGE_CLAUSE + "\n</tools>"
clauses.append(SHOW_IMAGE_CLAUSE)
if show_text:
clauses.append(SHOW_TEXT_CLAUSE)
if clauses:
tools_section = (
TOOLS_SECTION[: -len("</tools>")]
+ "\n\n".join(clauses)
+ "\n</tools>"
)
else:
tools_section = TOOLS_SECTION
return prompt + "\n<documents>\n" + body + "\n</documents>\n" + tools_section
+520
View File
@@ -0,0 +1,520 @@
"""Phase 182 (task 02): the verbatim locator grammar, the slicer, the resolver.
The contract in one line: **the model names a location, this module supplies
the bytes** (PLAN §6's tool-surface extension, phase 182 D1–D7). An answer
never carries a model-retyped copy of a source file; it carries a ``bor``
locator fence (````` ```bor homelab/runbooks/nginx.md#12-40 `````), and the
bytes shown to the human are read out of the indexed ``documents.content``
row by :func:`block_for` / :func:`resolve_answer_blocks`. Because the
displayed text is never generated, it cannot be paraphrased, "corrected",
or hallucinated — the phase's whole point — and the same resolver serves
both entry points (the ``show_text`` tool's own block, task 03, and the
``done``-frame pass over the finished answer, task 05).
The module is deliberately split in two halves:
* the **pure** half — :func:`canonical_locator`, :func:`fences_in`,
:func:`slice_lines`, :func:`cap_slice` — no DB, no settings, no I/O;
* the **resolver** half — :func:`document_by_identity`, :func:`block_for`,
:func:`resolve_answer_blocks` — one bare ``SELECT`` per locator and the
kill-switch gate.
**Import rule (one-way, checkable with
``grep -n "import" app/rag/verbatim.py``):** this module must NEVER import
the agent module (:mod:`app.rag.agent`) — ``agent.py`` already depends on
the pure half of this module for the ``show_text`` tool branch, so a
reverse dependency cycles. Allowed here: ``sqlalchemy``,
:class:`app.models.Document`,
:data:`app.rag.retriever.TRUNCATION_MARKER`, :class:`app.config.Settings`,
stdlib.
Nothing calls this module yet (tasks 03 and 05 wire it in), so landing it
changes no behavior anywhere.
"""
from __future__ import annotations
import re
from collections.abc import Sequence
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.models import Document
from app.rag.retriever import TRUNCATION_MARKER
__all__ = [
"LOCATOR_LANG",
"VerbatimBlock",
"SliceResult",
"block_for",
"cap_slice",
"canonical_locator",
"document_by_identity",
"fences_in",
"resolve_answer_blocks",
"slice_lines",
]
#: The fence info-string token that marks a locator fence (D2). The
#: markdown renderer (task 06) and the copy-path builder (task 07) key on
#: this SAME constant — one grammar, one spelling, no drifting copy.
LOCATOR_LANG = "bor"
#: The range separators a locator may use between the identity and the
#: line range: a space, a colon, a bare ``#``, or ``#L`` (D7 — the grammar
#: is TOLERANT on input because the string is model-written).
_SEP = r"[ \t:#]+"
#: A line range: ``12-40``, ``12–40`` (en dash), ``L12-L40``, or a bare
#: ``12`` (one line). An optional ``L`` prefixes either bound; whitespace
#: around the dash is tolerated. Anchored at the end of the string by the
#: caller, so a range-shaped tail followed by more text is junk, not a
#: range.
_RANGE = r"(?P<range>L?[0-9]+(?:[ \t]*[-\u2013][ \t]*L?[0-9]+)?)"
#: The trailing range of a locator string (see :data:`_SEP` /
#: :data:`_RANGE`). Case-insensitive only for the ``L`` prefix.
_TRAILING_RANGE_RE = re.compile(f"{_SEP}{_RANGE}$", re.IGNORECASE)
#: The opening line of a ``bor`` locator fence: the language token,
#: whitespace, then a NON-EMPTY remainder (the locator). A bare
#: `` ```bor `` carries no locator, so it never matches and stays an
#: ordinary code block.
_FENCE_OPEN_RE = re.compile(
rf"^```{re.escape(LOCATOR_LANG)}[ \t]+(?P<locator>.*[^ \t])[ \t]*$"
)
#: The two characters a bound may use as its line-number prefix.
_LINE_PREFIX = "Ll"
@dataclass(frozen=True)
class VerbatimBlock:
"""One displayed verbatim block — the bytes plus the truth about them.
``start_line`` / ``end_line`` are **1-based and INCLUSIVE** and describe
EXACTLY the lines :attr:`text` contains *after* any character cap — a
capped block's ``end_line`` is the last line actually shown, never the
line the locator asked for (the caption must not claim lines nobody
sees). ``total_lines`` is the whole document's line count, so the
caption can say ``lines 12-40 of 512``. :attr:`text` never carries a
trailing newline (lines are joined with ``"\\n"``), and
:attr:`truncated` says whether :data:`~app.rag.retriever.TRUNCATION_MARKER`
was appended on its own last line.
:attr:`locator` is the CANONICAL form (D7) built by the factory
(:func:`block_for`), never passed in by a caller — it is the dedupe key
of :func:`resolve_answer_blocks` and the matching key the copy-path
builder uses (task 07).
"""
source: str
path: str
title: str
start_line: int
end_line: int
total_lines: int
text: str
truncated: bool = False
locator: str = ""
@dataclass(frozen=True)
class SliceResult:
"""The outcome of one line-range slice (see :func:`slice_lines`).
``first_line`` / ``last_line`` are 1-based and inclusive; ``past_end``
is the caller's refusal signal and always travels with the true
``total_lines`` (the teaching refusal names the real line count).
"""
text: str
first_line: int
last_line: int
total_lines: int
past_end: bool
def canonical_locator(raw: str) -> str | None:
"""Normalize one model-written locator string, or refuse it (D7).
Accepted forms — ``source/path`` (the ``read``/``grep`` identity
contract, split at the FIRST ``'/'``) followed by an optional separator
and line range:
========================== ==========================
input canonical output
========================== ==========================
``homelab/a.md`` ``homelab/a.md``
``homelab/a.md 12-40`` ``homelab/a.md#12-40``
``homelab/a.md:12-40`` ``homelab/a.md#12-40``
``homelab/a.md#12-40`` ``homelab/a.md#12-40``
``homelab/a.md#L12-L40`` ``homelab/a.md#12-40``
``homelab/a.md 12\u201340`` ``homelab/a.md#12-40``
``homelab/a.md 12`` ``homelab/a.md#12-12``
========================== ==========================
The canonical form is always ``source/path`` + ``#start-end`` with
plain digits — never the en dash, never the ``L``, so the fence, the
tool block and the UI share ONE string (D7).
Returns ``None`` (NOT a locator) for: empty/whitespace input, a value
with no ``'/'`` in the path part, a non-numeric or non-positive bound,
``start > end``, trailing junk after the range, or a leading ``bor``
token the caller left in. No DB access, no case folding (paths are
case-sensitive row values). A locator carrying a range also refuses an
identity containing whitespace: the fence's info string is ONE
whitespace-free identity plus the range, so a path that contains a
space is expressed as a whole-document locator (no range) or not at
all — the documented limit of the tolerant grammar.
"""
text = raw.strip()
if not text:
return None
# A caller that pasted the fence's language token in has already lost
# the identity — refuse rather than guess which token was the path.
if text.split(" ", 1)[0] == LOCATOR_LANG:
return None
match = _TRAILING_RANGE_RE.search(text)
if match is None:
identity, range_part = text, None
else:
# ``match.start()`` is where the separator begins — the identity
# is everything before it (a multi-character separator, e.g. two
# spaces, is consumed whole).
identity, range_part = text[: match.start()], match.group("range")
# The identity is one whitespace-free ``source/path`` token, and it
# never ends in a dangling separator (``a.md#`` with no range is a
# typo, not a whole document).
if not identity or any(c.isspace() for c in identity):
return None
if identity.endswith(("#", ":", "-")):
return None
if "/" not in identity:
return None
if range_part is None:
return identity
bounds = [b.strip().lstrip(_LINE_PREFIX) for b in re.split(r"[-\u2013]", range_part)]
# Defensive: :data:`_RANGE` already guarantees digit bounds, so this
# only fires if the grammar above is ever loosened — it keeps
# ``canonical_locator`` total (never an ``int()`` ValueError to a
# caller that hands it model-written text).
if any(not b.isdigit() for b in bounds):
return None
start = int(bounds[0])
end = int(bounds[1]) if len(bounds) == 2 else start
if start < 1 or end < 1 or start > end:
return None
return f"{identity}#{start}-{end}"
def fences_in(answer: str) -> list[str]:
"""Every ``bor`` locator fence's RAW locator string, in answer order.
Rules (the same shape ``markdown.js`` applies, task 06):
* the opening line is a fence whose info string is exactly
`` ```bor `` + whitespace + a NON-empty remainder — a bare
`` ```bor `` with no path is an ordinary code block, not a locator,
and is not swallowed;
* the fence closes at the next line that is nothing but backticks;
* an **unclosed** fence contributes nothing (defensive: a half-streamed
answer must not resolve a locator the model never finished) and ends
the scan, since its body runs to the end of the answer;
* the body is read and DISCARDED — this module never looks at what the
model typed inside the fence (the bytes come from the row, D1);
* nested `` ``` `` inside a body is NOT supported: the fence ends at
the first closer (documented limitation, same rule as the renderer).
Duplicates collapse keeping the FIRST occurrence.
"""
lines = answer.splitlines()
locators: list[str] = []
index = 0
while index < len(lines):
match = _FENCE_OPEN_RE.match(lines[index].strip())
if match is None:
index += 1
continue
closer = _find_fence_close(lines, index + 1)
if closer is None:
# Unclosed: the body runs to the end of the answer, so there
# is nothing further to scan and this fence yields nothing.
break
locator = match.group("locator").strip()
if locator and locator not in locators:
locators.append(locator)
index = closer + 1
return locators
def _find_fence_close(lines: Sequence[str], start: int) -> int | None:
"""Index of the fence's closing line at or after ``start``, ``None`` if unclosed."""
for offset in range(start, len(lines)):
stripped = lines[offset].strip()
if stripped.startswith("```") and not stripped.strip("`"):
return offset
return None
def slice_lines(
content: str, offset: int | None = None, limit: int | None = None
) -> SliceResult:
"""Cut ``content`` to a 1-based line range with ``read``'s exact semantics.
Same indexing (``content.splitlines()``) and the same range arithmetic
as the ``read`` tool (``app/rag/agent.py``) and ``grep``, so a line
number the model got from ``grep`` names the same line here — the one
shared coordinate system the whole phase depends on:
* ``offset`` omitted → 1; ``limit`` omitted → read to the end;
* ``end = min(offset + limit - 1, total_lines)`` — an overshooting
``limit`` CLAMPS (``last_line`` is the document's last line);
* ``offset`` past the end, an empty document, or a ``limit`` below 1 →
``past_end=True`` with empty text and the TRUE ``total_lines`` (the
caller builds the teaching refusal).
Never raises for a range problem: the refusal is the caller's, the
total always travels with the result.
"""
lines = content.splitlines()
total_lines = len(lines)
first = 1 if offset is None else offset
end = total_lines if limit is None else min(first + limit - 1, total_lines)
if total_lines == 0 or first > total_lines or end < first:
return SliceResult(
text="",
first_line=first,
last_line=0,
total_lines=total_lines,
past_end=True,
)
return SliceResult(
text="\n".join(lines[first - 1 : end]),
first_line=first,
last_line=end,
total_lines=total_lines,
past_end=False,
)
def cap_slice(text: str, first_line: int, cap: int) -> tuple[str, int, bool]:
"""Apply the character budget to one slice AT A LINE boundary.
Returns ``(text_to_show, last_line_shown, truncated)``. The whole
point of cutting at a line boundary is that the caption's line range
stays true: ``last_line_shown`` is the last line the reader actually
sees, so a capped block never claims lines it did not show.
* ``cap <= 0`` raises :class:`ValueError` — a mis-wired caller must be
loud. The ``show_text_max_chars == 0`` kill switch is checked by
:func:`resolve_answer_blocks`, never by reaching here;
* ``len(text) <= cap`` → the text untouched, its own last line,
``truncated=False``;
* otherwise cut to the last WHOLE line that fits and append the shared
:data:`~app.rag.retriever.TRUNCATION_MARKER` on its own line — the
marker is not content and is not counted in ``last_line_shown`` (a
line counts as fitting only when its terminator is inside the
budget, or sits exactly at the budget's edge);
* a first line LONGER than ``cap`` cuts that one line mid-way (the only
sub-line cut, documented) and still ends with the marker.
"""
if cap <= 0:
raise ValueError(f"cap_slice needs a positive cap, got {cap!r}")
if len(text) <= cap:
if not text:
return text, first_line, False
# ``split("\\n")``, NOT ``splitlines()``: a slice that ends on a
# BLANK line legitimately ends with ``"\\n"`` (line 5 of
# ``"a\nb\n\n"`` is empty and present), and ``splitlines()`` drops
# that final empty element — which would report an extent one
# line short of the text actually shown (a caption that lies, and
# a canonical locator that no longer matches the same range
# resolved by another entry point).
return text, first_line + len(text.split("\n")) - 1, False
cut = text[:cap]
if text[cap] == "\n":
# The budget lands exactly on a line end — every line of the cut
# is whole, nothing to trim.
return (
f"{cut}\n{TRUNCATION_MARKER}",
first_line + len(cut.splitlines()) - 1,
True,
)
boundary = cut.rfind("\n")
if boundary <= 0:
# No whole line fits — the single documented mid-line cut.
return f"{cut}\n{TRUNCATION_MARKER}", first_line, True
whole = cut[:boundary]
return (
f"{whole}\n{TRUNCATION_MARKER}",
first_line + len(whole.splitlines()) - 1,
True,
)
def document_by_identity(db: Session, combined: str) -> Document | None:
"""The indexed row at the combined ``source/path`` identity, or ``None``.
Split at the FIRST ``'/'`` — source names are directory basenames and
can never contain one, so the part before is the source and everything
after is the path (the same identity contract as
``app.rag.agent._resolve_path``).
This is that contract **reimplemented as a bare lookup**, deliberately
NOT a call into ``agent.py``: the tool branch already has
``_resolve_path`` (with its did-you-mean suggestions), the fence
resolver only needs yes/no, and importing the agent from here would
cycle (``agent.py`` imports this module). A traversal string
(``../../etc/passwd``) is simply a row that does not exist — there is
no file access here to guard, only a ``WHERE`` clause.
"""
if "/" not in combined:
return None
source, _, path = combined.partition("/")
return db.scalar(
select(Document).where(Document.source == source, Document.path == path)
)
def block_for(
db: Session,
combined: str,
*,
offset: int | None = None,
limit: int | None = None,
cap: int,
doc: Document | None = None,
) -> VerbatimBlock | None:
"""Build the one block a locator names, or ``None`` (nothing displays).
Resolves ``combined`` (or takes the caller's already-resolved ``doc``)
and refuses ``None`` for: no such document (including every
traversal-shaped identity), an ``is_image`` document (image display
belongs to ``show_image`` — LOCKED A6), and an empty or past-the-end
slice. Otherwise ``slice_lines`` → ``cap_slice`` → the block, with the
canonical :attr:`~VerbatimBlock.locator` built here (``source/path``,
plus ``#first-last`` when the request named a range **or** the cap cut
the slice — the form task 07 matches on).
"""
if doc is None:
doc = document_by_identity(db, combined)
if doc is None or doc.is_image:
return None
sliced = slice_lines(doc.content, offset, limit)
if sliced.past_end or not sliced.text:
return None
text, last_line, truncated = cap_slice(sliced.text, sliced.first_line, cap)
locator = f"{doc.source}/{doc.path}"
if offset is not None or limit is not None or truncated:
locator = f"{locator}#{sliced.first_line}-{last_line}"
return VerbatimBlock(
source=doc.source,
path=doc.path,
title=doc.title,
start_line=sliced.first_line,
end_line=last_line,
total_lines=sliced.total_lines,
text=text,
truncated=truncated,
locator=locator,
)
def resolve_answer_blocks(
db: Session,
answer: str,
shown: Sequence[VerbatimBlock],
settings: Settings,
) -> list[VerbatimBlock]:
"""The blocks an answer displays, in DISPLAY ORDER (the done-time pass).
Fuses the two ways a block enters an answer — the `` ```bor `` locator
fences the model wrote (resolved here, from the row) and the blocks the
``show_text`` tool already showed during the turn (``shown``) — into
the ONE list task 05 puts on the ``done`` frame and persists with the
record.
**Kill switches first** (checked HERE so no caller has to remember
them): ``[]`` when ``settings.answer_text`` is off, or
``settings.show_text_max_blocks == 0``, or
``settings.show_text_max_chars == 0``.
**Ordering rule** (task 07's DOM order and the E2E assertions depend on
it): answer order first — one block per fence, in the order the fences
appear — then the ``shown`` blocks the answer never located, in their
original call order. Dedupe is by canonical locator, and the **fence
wins** over a same-locator entry from ``shown`` because the fence
controls placement. A fence that does not resolve (unknown path, image
document, past-the-end range, malformed locator) is simply absent: the
caller sees "not among the blocks", which is what task 07 renders as
the unresolved state — the answer's own fence text is left alone, so
nothing is silently deleted. The list is finally truncated to
``settings.show_text_max_blocks`` — and the fence scan STOPS once that
many blocks are in, because the same truncation makes every later
block unreachable (the bound keeps a fence-stuffed answer to at most
``show_text_max_blocks`` successful lookups instead of one ``SELECT``
per fence on the request's event loop).
"""
if not settings.answer_text:
return []
if settings.show_text_max_blocks == 0 or settings.show_text_max_chars == 0:
return []
cap = settings.show_text_max_chars
blocks: list[VerbatimBlock] = []
#: The locator strings already CLAIMED (canonical written forms) — a
#: repeated fence never resolves twice, and an unresolvable fence
#: still holds its slot against a same-locator ``shown`` block.
claimed: set[str] = set()
#: The locators of the blocks actually appended — a resolved block
#: can carry a NARROWER locator than the locator its fence wrote (the
#: char cap cut the slice), and the same range shown by the tool
#: carries exactly that narrower key, so both keys dedupe.
emitted: set[str] = set()
for raw in fences_in(answer):
canonical = canonical_locator(raw)
if canonical is None or canonical in claimed:
continue
# Reserve the key BEFORE resolving: an unresolvable fence must not
# let a same-locator ``shown`` block jump into the fence's slot.
claimed.add(canonical)
block = _block_for_canonical(db, canonical, cap)
if block is None or block.locator in emitted:
continue
claimed.add(block.locator)
emitted.add(block.locator)
blocks.append(block)
if len(blocks) >= settings.show_text_max_blocks:
# The display budget is already spent — the truncation below
# makes every later block unreachable, so stop LOOKING too:
# a fence-stuffed answer costs at most
# ``show_text_max_blocks`` successful lookups instead of one
# ``SELECT`` per fence on the request's event loop.
break
for block in shown:
if block.locator in emitted or block.locator in claimed:
continue
emitted.add(block.locator)
blocks.append(block)
return blocks[: settings.show_text_max_blocks]
def _block_for_canonical(db: Session, canonical: str, cap: int) -> VerbatimBlock | None:
"""Resolve one ALREADY-CANONICAL locator (the output of :func:`canonical_locator`)."""
identity, _, range_part = canonical.partition("#")
if not range_part:
return block_for(db, identity, cap=cap)
start_str, _, end_str = range_part.partition("-")
start = int(start_str)
end = int(end_str)
return block_for(db, identity, offset=start, limit=end - start + 1, cap=cap)
+124 -7
View File
@@ -182,6 +182,47 @@ class SourceRef(BaseModel):
return data
class VerbatimBlockRef(BaseModel):
"""One SERVER-resolved verbatim block of a chat answer (phase 182).
The item of :attr:`ChatDoneEvent.answer_text` and of the persisted
:class:`ChatMessage.answer_text` — the display block the
``show_text`` tool or a `` ```bor `` locator fence asked for, with
the exact bytes read out of ``documents.content`` by
:func:`app.rag.verbatim.block_for`.
**``text`` is SERVER-supplied bytes, never model output** (LOCKED
A4/A7): the model only ever names the location, so what the user
reads and copies cannot differ from the indexed file by a character
— the phase's whole reason for existing. The field carries the same
600 000 cap as the phase-83/164 answer text (deliberately generous
above the 12 000 default of ``BOR_SHOW_TEXT_MAX_CHARS``, so an
operator raising that budget never hits the wire limit instead).
``start_line`` / ``end_line`` are **1-based and INCLUSIVE** and
describe exactly the lines ``text`` contains AFTER any character cap
— they are the SERVER's resolved extent, never the model's claim
(LOCKED A2), which is what the panel's caption prints. The caps on
``source`` / ``path`` / ``title`` are :class:`SourceRef`'s (the same
``documents`` column lengths, phase 83 SEC-05); ``locator`` is the
canonical matching key (phase 182 D7 — ``source/path`` or
``source/path#12-40``) the client matches its rendered placeholder
against.
"""
source: str = Field(max_length=120)
path: str = Field(max_length=1000)
title: str = Field(max_length=500)
start_line: int = Field(ge=0)
end_line: int = Field(ge=0)
total_lines: int = Field(ge=0)
#: The verbatim bytes themselves (see the class docstring) — the
#: phase-83 answer-text cap, generous above the per-block budget.
text: str = Field(max_length=600_000)
truncated: bool = False
locator: str = Field(max_length=2100)
class ChatThinkingEvent(BaseModel):
"""SSE thinking event: one chunk of the model's reasoning (phase 17).
@@ -342,6 +383,23 @@ class ChatDoneEvent(BaseModel):
byte-identical to pre-phase (the byte-identical-off contract,
PLAN §13; the :attr:`answer_image` / :class:`SourceRef.image_url`
omission precedent).
Phase 182 (task 05, LOCKED A1/A4/A9): ``answer_text`` — the answer's
VERBATIM display blocks, the text sibling of :attr:`answer_image`
with two deliberate differences. (1) It is a LIST: one answer may
display several blocks (the ``show_text`` calls and/or the
`` ```bor `` locator fences the answer itself wrote — D1), in
DISPLAY order (answer order first, then the tool blocks the answer
never located — :func:`app.rag.verbatim.resolve_answer_blocks`).
(2) Each item carries the TEXT itself rather than a route: the bytes
ride inline (A4 — no new bytes route, no new anonymous surface, so a
shared page renders from its own record exactly like the phase-123
chat image), and every byte of it comes from ``documents.content``,
never from the model (A7). ``None`` / ``[]`` — every turn without
blocks, every deflected turn (D9 resolves nothing on a disclaimer),
every ``answer_text=False`` turn, and the whole pre-phase — is
DROPPED on serialization: ABSENT, never ``null``, never ``[]``, so
those frames stay byte-identical to pre-phase (the same contract).
"""
type: str = "done"
@@ -357,25 +415,36 @@ class ChatDoneEvent(BaseModel):
#: ONLY on the cap-reached turn; ``False`` (every other turn)
#: serializes WITHOUT the key — ABSENT, never ``false``.
budget_exhausted: bool = False
#: Phase 182 (task 05) — see the class docstring. ``None``/``[]``
#: (every turn without verbatim blocks) serializes WITHOUT the key —
#: ABSENT, never ``null``, never ``[]``.
answer_text: list[VerbatimBlockRef] | None = None
@model_serializer(mode="wrap")
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
"""The phase-124 answer-image omission rule: ``answer_image``
``None`` (every deflected / image-less / pre-phase turn)
serializes WITHOUT the key — ABSENT, never ``null`` — so those
done frames stay byte-identical to pre-phase (the
"""The additive-key omission rules: ``answer_image`` ``None``
(every deflected / image-less / pre-phase turn) serializes
WITHOUT the key — ABSENT, never ``null`` — so those done frames
stay byte-identical to pre-phase (the
:class:`SourceRef.image_url` / phase-123 ``image`` omission
precedents). A shown ref keeps all its fields (its own
serializer drops a ``None`` ``image_url`` — a shown doc is an
image doc, so in practice it always carries it). Phase 160
(task 03): ``budget_exhausted`` ``False`` (every non-cap turn)
serializes WITHOUT the key too — ABSENT, never ``false`` — the
same byte-identical-off contract."""
same byte-identical-off contract. Phase 182 (task 05):
``answer_text`` ``None`` OR EMPTY (a turn whose answer resolved
no block, a deflected turn, the whole ``answer_text=False``
configuration, and the pre-phase) serializes WITHOUT the key —
ABSENT, never ``null`` and never ``[]``, so every block-free
frame keeps its exact pre-phase bytes."""
data = handler(self)
if self.answer_image is None:
data.pop("answer_image", None)
if not self.budget_exhausted:
data.pop("budget_exhausted", None)
if not self.answer_text:
data.pop("answer_text", None)
return data
@@ -1478,6 +1547,28 @@ class ChatMessage(BaseModel):
# exactly like ``stopped`` — the phase-50 byte-identical
# round-trip contract covers the new key automatically.
budget_exhausted: bool | None = None
# Phase 182 (task 05, LOCKED A4/A9): the answer's VERBATIM display
# blocks — the persisted twin of the done frame's ``answer_text``
# (the ``show_text`` tool's blocks and/or the ones a `` ```bor ``
# locator fence resolved), on the BRAIN record only. Like
# ``answer_image`` the field EXISTS so the ``extra="forbid"``
# boundary does NOT 422 the done-time auto-save carrying it (the
# phase-113 lesson, applied up front), and the restore path plus the
# anonymous shared page re-render the panels from it — the bytes are
# part of the chat's own content, so they ride the same
# credential-is-the-row trust model and need NO new route (A4).
# No migration (``saved_chats.messages`` is raw JSONB, the phase-50
# / 160 precedent). ``None``/``[]`` serializes WITHOUT the key (the
# :meth:`_drop_image_when_absent` serializer below), so a turn with
# no blocks — and every pre-phase record — round-trips
# byte-identically (A9). The per-block ``text`` cap is
# :class:`VerbatimBlockRef`'s own; the list cap matches the
# ``tools``/``segments`` cap (50), far past the
# ``show_text_max_blocks`` default of 5 — and the block bytes count
# toward :data:`SAVED_CHAT_TOTAL_CHARS_BUDGET`, so the display blocks
# never become a second, unbounded channel on the anonymous save
# surface (the phase-83 SEC-05 invariant).
answer_text: list[VerbatimBlockRef] | None = Field(default=None, max_length=50)
@model_serializer(mode="wrap")
def _drop_image_when_absent(self, handler: SerializerFunctionWrapHandler) -> Any:
@@ -1492,12 +1583,21 @@ class ChatMessage(BaseModel):
record WITH one keeps it — the user bubble, the refreshed page,
and the shared chat all render the image from the served route
(the image is part of the chat's content, so it rides the same
public/credential-is-the-id trust model)."""
public/credential-is-the-id trust model).
Phase 182 (task 05) extends the same rule to ``answer_text``:
``None`` OR an empty list (every record without verbatim blocks,
and every pre-phase-182 record) serializes WITHOUT the key —
ABSENT, never ``null``, never ``[]`` (A9), so those records
round-trip byte-identically while a record WITH blocks keeps the
server's bytes for the restore path and the shared page."""
data = handler(self)
if self.image is None:
data.pop("image", None)
if self.answer_image is None:
data.pop("answer_image", None)
if not self.answer_text:
data.pop("answer_text", None)
# Phase 158 (task 01, D4): the timeline is ABSENT (never
# null) when there is no run to render — the pre-phase /
# no-thinking-no-tools shape stays byte-identical (the
@@ -1529,11 +1629,28 @@ def _require_within_total_budget(messages: list[ChatMessage]) -> list[ChatMessag
(phase 164): ``len(text) + len(thinking or "")`` over every message
must stay within :data:`SAVED_CHAT_TOTAL_CHARS_BUDGET`.
Phase 182 (task 05) counts the VERBATIM BLOCKS into the same sum: a
block's ``text`` carries the phase-83 answer-text cap (600 000) so an
operator raising ``BOR_SHOW_TEXT_MAX_CHARS`` never hits the wire limit,
and the anonymous write surface (``POST/PUT /api/chats`` is public —
phase 55 A1) must not gain a second, UNBOUNDED byte channel next to
the bounded one (the SEC-05 invariant: the total is what keeps the
audited worst case at the audited order). A legitimate turn carries at
most ``BOR_SHOW_TEXT_MAX_BLOCKS`` × ``BOR_SHOW_TEXT_MAX_CHARS`` bytes
(5 × 12 000 = 60 KB by default), orders of magnitude below the budget,
so nothing the server produces is affected — the bound only rejects a
client stuffing blocks.
Raises :class:`ValueError` naming the budget, which Pydantic turns
into the ordinary 422 validation response (``loc`` = the model
itself, ``msg`` carrying the budget wording) — the same boundary-
only style as every other phase-83/164 cap."""
total = sum(len(m.text) + len(m.thinking or "") for m in messages)
total = sum(
len(m.text)
+ len(m.thinking or "")
+ sum(len(b.text) for b in m.answer_text or [])
for m in messages
)
if total > SAVED_CHAT_TOTAL_CHARS_BUDGET:
raise ValueError(
f"saved chat exceeds the {SAVED_CHAT_TOTAL_CHARS_BUDGET:,}-character budget"
+111 -34
View File
@@ -550,6 +550,19 @@ export function documentUrl(source, path, back = "/") {
return url;
}
/* Phase 182 (task 07): the ONE options object handed to the verbatim panel
* client (assets/verbatim.js, a classic script loaded before this module),
* so every panel on this page navigates EXACTLY like a source chip: the
* same `documentUrl(…, "/")` href (back → the chat, phase 153) and the same
* left-click → same-page modal (phase 26). The shared page passes no such
* hook — there the plain `/document.html?…` href verbatim.js builds by
* default is the whole affordance (no modal, admin-gated API). */
const VERBATIM_LINK_OPTS = {
documentHref: (source, path) => documentUrl(source, path, "/"),
onOpen: (source, path, opener) =>
openDocumentModal(source, path, opener, "/"), // back → the chat page
};
/* ---------- steering notes (phase 15; panel module-owned from phase 34) ----------
*
* The owner's tuning notes steer every future answer: they live in
@@ -2916,7 +2929,15 @@ function renderStoredMessage(m) {
}
return;
}
const wrap = addMessage("brain", renderMarkdown(m.text), true);
// Phase 182 (task 07): the brain restore renderer OPTS IN the verbatim
// pass (the ONE answer renderer pair — this and the live per-delta write),
// so a stored ` ```bor ` fence becomes the same placeholder the live turn
// painted and hydrates from the record's `answer_text` below. A pre-phase
// record without the key renders its fences as the honest unresolved
// state — the record is what it is, the client never resurrects model
// bytes (locked A7). USER bubbles and the thinking blocks stay
// one-argument calls: their text is not a locator surface.
const wrap = addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true);
// Phase 158 (task 03, D4): a record WITH `segments` renders the
// per-run timeline through restoreTimeline (collapsed) — the runs
// carry the SAME data as `m.thinking` + `m.tools`, in order, so
@@ -3009,6 +3030,18 @@ function renderStoredMessage(m) {
// the chips row, then the images row). A record without the key
// renders byte-identically (pre-phase chats restore unchanged).
if (m.answer_image) appendAnswerImage(wrap, m.answer_image);
// Phase 182 (task 07): the verbatim panels restore WITH the bubble, in the
// SAME order as the live `done` path — hydrate the placeholders inside the
// bubble, then append the blocks the answer never located, then the source
// clusters (the phase-124/130 position: below the text, above the chips).
// The SAME builders as the live turn, so a reload renders the identical
// panel (parity is the phase's hard requirement); a record with no
// `answer_text` settles its placeholders to the unresolved state.
hydrateVerbatim(wrap.querySelector(".bubble"), m.answer_text || [], VERBATIM_LINK_OPTS);
appendUnlocatedVerbatim(wrap, m.answer_text || [], VERBATIM_LINK_OPTS);
// The settled answer's ordinary code blocks get their Copy buttons here
// too (never mid-stream — the live path does the same at `done`).
enhanceAnswerCodeBlocks(wrap);
appendSources(wrap, m.sources);
// Phase 113 (task 02) / phase 130 (task 02): the related tier
// restores with the bubble — BELOW the citation clusters and ABOVE
@@ -3235,44 +3268,27 @@ function absoluteShareUrl(shareUrl) {
return new URL(shareUrl, window.location.origin).toString();
}
/* Select every text node in an element — the link field's
* select-on-focus (an <a> has no .select(); a range does the job).
* Best-effort: selection failure only means the user copies by hand. */
function selectAllInField(el) {
try {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch {
/* selection is best-effort — the field still shows the full URL */
}
}
/* Clipboard copy with the owner-locked inline-link fallback: a
* non-secure (http) homelab origin rejects navigator.clipboard, so the
* failure path renders a TRANSIENT <a> link field near the status line
* (appended to the composer, beside the send button that carries
* #send-status) — input-like, it selects its full URL on focus (click
* or Tab, then Ctrl/Cmd+C). One field at a time (a new offer replaces
* the old). Returns true when the clipboard took it. */
* the old). Returns true when the clipboard took it.
*
* Phase 182 (task 07): the mechanism DELEGATES to the one implementation in
* assets/verbatim.js (`copyTextWithFallback` + its select-on-focus helper),
* because the verbatim panels and the ordinary answer code blocks needed the
* same degradation and a second copy would have been a second source of
* truth. Nothing else changes: the same host (the composer, beside the
* status line), the same owner-locked field title, the same return value —
* the share suites are the guard (tests/unit/test_save_chat_ui.py). */
async function copyShareLinkWithFallback(absoluteUrl) {
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
try {
await navigator.clipboard.writeText(absoluteUrl);
return true;
} catch {
const field = document.createElement("a");
field.className = "share-link-fallback";
field.href = absoluteUrl; // carries the full URL (copy link address works too)
field.textContent = absoluteUrl; // the URL is data — textContent, never innerHTML
field.title = "Share link — click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => selectAllInField(field));
composer.appendChild(field); // near the status line (inside the send button)
field.focus({ preventScroll: true }); // selects the URL — ready to copy
return false;
}
return copyTextWithFallback(
absoluteUrl,
composer, // near the status line (inside the send button)
"Share link — click, then copy (Ctrl/Cmd+C)"
);
}
/* Share-success toast (phase 55, task 04 — TODO.md L5): the VISIBLE
@@ -3903,6 +3919,10 @@ function finalizeFailedTurn(
// open run closed (phase 158, D5 — the stop-finalize pattern),
// mark it failed.
closeAllRuns(wrap);
// Phase 182 (task 07): the failure funnel is a settle as well — a partial
// stream that carried a locator fence keeps its text, but the placeholder
// settles to the unresolved state (no `done`, no server bytes).
settleVerbatimPlaceholders(wrap.querySelector(".bubble"), "unverified");
// Phase 161 (D2): the failed-turn funnel is a settle — fold the
// container alongside the runs; the summary reads the live run
// count (D6 — segRuns, module scope).
@@ -4028,6 +4048,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
// set first, so the catch never reads the guard's
// abort as a user stop
// Phase 182 (task 07): the 300 s guard is a terminal frame — a panel
// still reading "Retrieving the exact text…" would be a lie by then
// (the hydrate at `done` never runs), so settle it to the honest state.
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
});
@@ -4329,7 +4353,14 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// NEW run in the container — the D15 re-open of the shared
// block is gone). Idempotent — a no-op without an active run.
closeCurrentRun();
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// Phase 182 (task 07): the OPT-IN verbatim pass on the ONE answer
// renderer. A ` ```bor ` locator fence becomes a pending placeholder
// here (the model's re-typed body DROPPED, locked A7) that the `done`
// branch below hydrates against the frame's server-resolved blocks —
// mid-stream the reader sees "Retrieving the exact text…", never a
// copy that might be wrong. Every other renderer on the page (user
// bubbles, thinking runs, the document viewer) stays one-argument.
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc, { verbatim: true });
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
@@ -4361,6 +4392,25 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// figure is additive — "summaries should still be provided");
// a frame without it renders byte-identically (no figure DOM).
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
// Phase 182 (task 07, locked A1/A2): the answer's verbatim blocks —
// `ev.answer_text`, the bytes the SERVER read out of the index — land
// in the SAME band as the answer image (below the text, above the
// source clusters): hydrate every placeholder the renderer painted,
// then append the blocks the answer never located (a `show_text` call
// the prose never named). A locator the server could not resolve
// renders the honest unresolved sentence with NONE of the model's
// bytes, and no panel is left at "Retrieving the exact text…".
hydrateVerbatim(
wrap.querySelector(".bubble"),
ev.answer_text || [],
VERBATIM_LINK_OPTS
);
appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS);
// The turn SETTLED — this is where the ordinary code blocks get their
// Copy buttons (never mid-stream: per-delta the bubble is rebuilt from
// scratch, and a button that vanishes on the next token is not a
// control). NO badge, NO caption on those (locked A5).
enhanceAnswerCodeBlocks(wrap);
appendSources(wrap, ev.sources);
// Phase 113 (task 02): the cited tier stays the citation
// surface (the chips above); the related tier (scored docs
@@ -4378,7 +4428,11 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
// Phase 182 (task 07): the same opt-in as the per-delta write —
// this line paints the answer the user keeps (the fallback text,
// so no placeholder exists in practice, but the ONE answer
// renderer must stay ONE code path in shape).
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText, { verbatim: true });
}
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata; phase 17: + optional
@@ -4405,6 +4459,13 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// the precedent): an image-less / pre-phase turn stores a
// byte-identical record.
answer_image: ev.answer_image || undefined,
// Phase 182 (task 07): the verbatim blocks persist with the turn
// (task 05's additive `answer_text` key) so a reload — and a shared
// page — re-renders the identical panel. `undefined` drops the key
// from the JSON (the house optional-meta pattern, the answer_image
// line above is the precedent): a pre-phase or answer_text=False
// record stays byte-identical.
answer_text: ev.answer_text || undefined,
// Phase 113 (task 02): the related tier persists with the
// turn (undefined drops the key from the JSON — the house
// optional-meta pattern), so the restore path re-renders the
@@ -4477,6 +4538,14 @@ async function runTurn(text, { reask = false, image = null } = {}) {
}
}
} else if (ev.type === "error") {
// Phase 182 (task 07): an `error` frame is terminal — the `done`
// hydrate will never run, so a placeholder that streamed in stays at
// "Retrieving the exact text…" forever unless it settles HERE (the
// honest unresolved state, never a stale promise of the exact text).
settleVerbatimPlaceholders(
wrap?.querySelector(".bubble"),
"unverified"
);
// Phase 114 (TODO L6): carry the frame's optional hint (the
// "question too long" frame has one) through the throw — the
// banner shows it in place of the default reachability hint.
@@ -4531,6 +4600,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
}
} catch (err) {
if (aborted) {
// Phase 182 (task 07): defensive settle for the guard's own abort
// surface (the timeout callback already ran it — idempotent, and the
// turn must not leave a pending panel on ANY terminal path).
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
// The 300s guard already took the turn to the error state — its
// own abort surfaces here, and it is never read as a user stop.
// Phase 159 (task 03): the aborted settle must not strand the
@@ -4551,6 +4624,10 @@ async function runTurn(text, { reask = false, image = null } = {}) {
// convention; no sources/suggestions — the turn never settled).
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
// which aborts the same fetch — from saving the partial twice.
// Phase 182 (task 07): the STOP is terminal for this answer too — a
// placeholder in the kept partial settles to the honest state (the
// blocks never arrive without a `done` frame).
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
if (wrap && acc && !persistedOnLeave) {
// Phase 158 (D5): the turn was stopped — fold every open run
// (replaces the phase-117 closeToolCalls fold + the D15 block
+138 -6
View File
@@ -102,6 +102,43 @@
* out of scope. Everything without a link/image/`####`+ construct renders
* byte-identically to
* the pre-phase renderer (pinned by the phase-180 unit suite).
*
* Phase 182 (task 06) adds the OPT-IN verbatim pass and the second,
* optional argument: `renderMarkdown(md, opts)`, where the ONLY truthy path
* is `opts && opts.verbatim === true`. When on, a ` ```bor ` LOCATOR fence
* (``` ```bor homelab/runbooks/nginx.md#12-40 ````) is replaced — info
* line, body and closing fence — with a `\u0000VERBATIMn\u0000` placeholder
* that the final restore step emits as
* `<div class="verbatim verbatim-pending" data-bor-locator="…">`.
* WHY THE MODEL'S BODY IS DISCARDED (the phase's core contract, locked A7):
* the bytes inside a `bor` fence are the MODEL's re-typed copy of a
* knowledge-base file, and a re-typed copy is exactly the bug this phase
* kills — it can silently differ from the indexed bytes. So the renderer
* keeps only the LOCATION (normalized by the local `borLocator`, the client
* mirror of Python `app.rag.verbatim.canonical_locator`) and paints nothing
* but the pending caption; the server
* (`app.rag.verbatim.resolve_answer_blocks`) supplies the real bytes and
* `verbatim.js` (task 07) hydrates the placeholder by matching
* `data-bor-locator`. An UNPARSEABLE locator is put back byte-for-byte and
* falls through to the ordinary fence pass — no placeholder, no badge, the
* model's own body — and a fence left OPEN at the very end of the input
* (the info line typed, the body not yet streamed) shows that same pending
* state instead of raw backticks. The server's `fences_in` is STRICTER
* there (it ignores an unclosed fence: the server only ever sees a finished
* answer, the client sees a partial one); a half-typed BODY therefore still
* reads as ordinary text until its closing fence lands, exactly like every
* other incomplete fence does today. PASS ORDER with the option on:
* verbatim → fences → tables → escape+inline → blockquotes → paragraphs →
* restore (a ` ``` ` inside a `bor` body ends the fence at the FIRST closer
* — the documented limitation the server shares, so both sides agree). The
* placeholder is the one block element the paragraph pass lets through
* unwrapped. BYTE-IDENTICAL WHEN OFF: with no second argument — every
* existing call site, the document viewer (`document.js`), the thinking
* blocks and the user bubbles — `opts` is undefined, the pass never runs,
* no `\u0000VERBATIM` placeholder exists anywhere in the text, the restore
* replace is a no-op, and a document that legitimately contains a ` ```bor `
* fence still renders its own text (the phase-44/138/180 fixtures stay
* byte-identical, pinned by their own suites).
*/
function escapeHtml(s) {
@@ -156,10 +193,83 @@ function mdAnchorOpen(safe) {
);
}
function renderMarkdown(md) {
// Phase 182 (task 06): the CLIENT MIRROR of Python
// `app.rag.verbatim.canonical_locator` — the tolerant locator grammar of
// phase 182 D7, re-implemented here because the renderer runs in the
// browser and cannot import Python. PARITY IS LOAD-BEARING: the canonical
// string this returns becomes the `data-bor-locator` key that `verbatim.js`
// (task 07) matches against the server's `done.answer_text[].locator`, so a
// divergence means a resolved block never hydrates. The parity table in
// tests/unit/test_frontend_verbatim_markdown.py pins both sides; `fences_in`
// on the server pins the fence shape.
//
// Grammar: a combined `source/path` (the `read`/`grep` identity contract,
// split at the FIRST `/`) plus an OPTIONAL separator (` `, `:`, `#`, `#L`)
// and line range — `12-40`, `12\u201340` (en dash), `L12-L40`, or a bare `12`
// (one line). Returns `source/path` for a whole document and
// `source/path#12-40` for a range (canonical DIGITS only, never the en dash,
// never the `L`), or `""` (NOT a locator) for an empty/whitespace input, a
// value with no `/` in the identity, a non-numeric or non-positive bound,
// `start > end`, trailing junk after the range, a dangling separator
// (`a.md#`), or a leading `bor` token the caller left in.
function borLocator(raw) {
const text = String(raw).trim();
if (!text) return "";
// A caller that pasted the fence's language token in has already lost the
// identity — refuse rather than guess which token was the path.
if (text.split(" ")[0] === "bor") return "";
// Leftmost match of: one-or-more separators + the range, ANCHORED to the
// end of the string (so a range-shaped tail followed by more text is
// junk, not a range). Everything before it is the identity.
const match = /[ \t:#]+(L?\d+(?:[ \t]*[-\u2013][ \t]*L?\d+)?)$/i.exec(text);
const identity = match ? text.slice(0, match.index) : text;
const rangePart = match ? match[1] : null;
// One whitespace-free `source/path` token, never ending in a dangling
// separator (`a.md#` with no range is a typo, not a whole document).
if (!identity || /\s/.test(identity)) return "";
if (/[#:\-]$/.test(identity)) return "";
if (!identity.includes("/")) return "";
if (rangePart === null) return identity;
const bounds = rangePart.split(/[\u2013-]/).map((b) => b.trim().replace(/^[Ll]+/, ""));
if (bounds.some((b) => !/^\d+$/.test(b))) return "";
const start = parseInt(bounds[0], 10);
const end = bounds.length === 2 ? parseInt(bounds[1], 10) : start;
if (start < 1 || end < 1 || start > end) return "";
return `${identity}#${start}-${end}`;
}
function renderMarkdown(md, opts) {
// 0. Protect the phase-182 ` ```bor ` LOCATOR fences — BEFORE the fence
// pass, so a locator fence never reaches the ordinary code renderer.
// OPT-IN ONLY (the byte-identity contract in the header): `opts` is
// undefined at every existing call site and `opts.verbatim === true` is
// the single truthy path. The whole match is DROPPED — info line, body
// and closing fence — because the model's copy of the file is exactly
// what must never be painted (locked A7); only the normalized locator is
// kept. The closing alternative (`(?![\s\S])`) matches a fence left OPEN
// at the very end of the input, so as soon as the model has typed the
// info line the answer shows the pending state instead of raw backticks
// (the deliberate asymmetry with the server's stricter `fences_in`: the
// server only ever sees a finished answer, the client sees a partial
// one). A bare ` ```bor ` with no path does NOT match — it is an ordinary
// code block and must survive to the fence pass untouched — and an
// unparseable locator is returned byte-for-byte, degrading to the
// `<pre><code>` that pass produces today with the model's own body.
const verbatim = [];
let text = md;
if (opts && opts.verbatim === true) {
text = text.replace(/^```bor[ \t]+\S[^\n]*\n(?:[\s\S]*?^```[ \t]*$|(?![\s\S]))/gm, (match) => {
const infoLine = match.slice(0, match.indexOf("\n")).replace(/^```bor[ \t]+/, "").trim();
const canonical = borLocator(infoLine);
if (canonical === "") return match;
verbatim.push(canonical);
return `\u0000VERBATIM${verbatim.length - 1}\u0000`;
});
}
// 1. Protect fenced code blocks.
const codeBlocks = [];
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
});
@@ -356,19 +466,41 @@ function renderMarkdown(md) {
}
text = quoted.join("\n");
// 3. Paragraphs (double newline separated).
// 3. Paragraphs (double newline separated). The first guard test is the
// untouched pre-existing one; the second is the phase-182 placeholder,
// which restores to a `<div>` (a BLOCK element) and must not be wrapped
// in a `<p>` the way a code placeholder is. It can only ever match when
// `opts.verbatim` was on, so every other caller keeps byte-identical
// paragraph behavior.
text = text
.split(/\n{2,}/)
.map((block) => {
const b = block.trim();
if (!b) return "";
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b)) return b;
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b) || /^\u0000VERBATIM\d+\u0000$/.test(b)) return b;
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
})
.join("");
// 4. Restore protected spans: tables (already final HTML) and code.
// 4. Restore protected spans: tables (already final HTML), code, and the
// phase-182 verbatim placeholders. A placeholder restores as the PENDING
// panel and NEVER as the model's bytes (locked A7): the caption carries
// the `source/path` identity, the state line is a polite live region
// (`role="status"` announces the pending text once and the task-07
// hydration swap as a change), and every interpolated value goes through
// escapeHtml — the locator is model-written text landing in an ATTRIBUTE,
// so this is the escape-first boundary with no exception.
return text
.replace(/\u0000TABLE(\d+)\u0000/g, (_m, i) => tables[Number(i)])
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)])
.replace(/\u0000VERBATIM(\d+)\u0000/g, (_m, i) => {
const canonical = verbatim[Number(i)];
const identity = canonical.split("#")[0];
return (
`<div class="verbatim verbatim-pending" data-bor-locator="${escapeHtml(canonical)}">` +
`<span class="verbatim-caption">${escapeHtml(identity)}</span>` +
`<span class="verbatim-state" role="status">Retrieving the exact text…</span>` +
"</div>"
);
});
}
+47 -3
View File
@@ -64,6 +64,25 @@ const invalidEl = document.querySelector("#shared-invalid");
* literal is only the no-config fallback. */
const brand = () => window.BOR_BRAND || "Brain of Reese";
/* Phase 182 (task 07): the shared page's verbatim link target — the plain
* `/document.html?source=…&path=…` href, with NO modal hook (this page has
* no document modal, and the documents API is admin-gated: a guest who
* follows the link lands on the sign-in page, which is the honest answer to
* "show me that file"). verbatim.js builds this same href by default; naming
* it here is the parity statement — the SAME panel client, the SAME three
* globals the chat page calls, so a shared turn renders identically to the
* owner's (the phase's hard requirement). */
const SHARED_VERBATIM_OPTS = {
documentHref: (
source,
path
) =>
"/document.html?source=" +
encodeURIComponent(source) +
"&path=" +
encodeURIComponent(path),
};
/* ---------- the token (from the URL) ----------
* The last path segment of /shared/<token>. A malformed or missing
* token (a path without a final segment, or a non-uuid segment) →
@@ -608,9 +627,13 @@ function addAnswerImage(wrap, ref) {
* the deflection treatment + the plain-text "Maybe try" chips, the
* brain answer's image figure (phase 124, task 06 — a plain
* non-interactive <figure>), the plain-text source chips, and the
* stopped note. NO interactive markup is ever created here — no
* stopped note. NO interactive markup is ever created HERE — no
* buttons, no forms, no links, no click handlers (owner-locked:
* zero controls). Markdown goes
* zero controls). The ONE exception is phase 182's verbatim panel, and it
* is not built HERE either: hydrateVerbatim / appendUnlocatedVerbatim /
* enhanceAnswerCodeBlocks (assets/verbatim.js, the classic-script client the
* chat page calls too) own that Copy button and that plain viewer link —
* parity first, so a guest reads the owner's answer. Markdown goes
* through the global escape-first renderMarkdown (markdown.js): the
* stored payloads are raw text, so the renderer's XSS safety applies
* unchanged. */
@@ -628,7 +651,12 @@ function renderSharedMessage(m) {
}
return;
}
const wrap = addSharedMessage("brain", renderMarkdown(m.text));
// Phase 182 (task 07): the shared page's answer renderer OPTS IN the
// verbatim pass — the same opt-in as the chat page's restore, so a stored
// ` ```bor ` fence paints the same placeholder and hydrates from the
// record's `answer_text` below. The USER bubble above stays one-argument
// (a question is never a locator).
const wrap = addSharedMessage("brain", renderMarkdown(m.text, { verbatim: true }));
// Phase 158 (task 03, D4): a record WITH `segments` renders the
// per-run timeline through addSegments (collapsed — the parity
// copy of the chat page's restoreTimeline); the runs carry the
@@ -654,8 +682,24 @@ function renderSharedMessage(m) {
// (below the text, above the chip row). A record without the key
// renders byte-identically (no figure DOM).
addAnswerImage(wrap, m.answer_image);
// Phase 182 (task 07): the verbatim panels render with the bubble in the
// SAME order as the chat page's restore — hydrate the placeholders inside
// the bubble, append the blocks the answer never located, then the source
// chips (below the text, above the clusters). The panel DOM, the Copy
// button and the unresolved sentence all come from verbatim.js — the ONE
// implementation shared with the chat page (parity, not a second copy);
// this file still builds no control of its own.
hydrateVerbatim(
wrap.querySelector(".bubble"),
m.answer_text || [],
SHARED_VERBATIM_OPTS
);
appendUnlocatedVerbatim(wrap, m.answer_text || [], SHARED_VERBATIM_OPTS);
addSources(wrap, m.sources);
if (m.stopped) addStoppedNote(wrap);
// The settled answer's ordinary code blocks get the same Copy button the
// chat page gives them (no badge, no caption — locked A5).
enhanceAnswerCodeBlocks(wrap);
}
/* ---------- the public read ----------
+294
View File
@@ -701,6 +701,289 @@ body::before {
.doc-md h5, .msg .bubble h5, .thinking-text h5 { margin: 0.8rem 0 0.3rem; font-size: 0.95rem; }
.doc-md h6, .msg .bubble h6, .thinking-text h6 { margin: 0.7rem 0 0.3rem; font-size: 0.88rem; }
/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------
A ` ```bor <source/path> <range> ` locator fence (markdown.js step 0) and
its hydrated panel (verbatim.js, task 07): the answer shows the EXACT
bytes of an indexed file, typed by the SERVER, with a caption whose
numbers are the server's resolved extent, a VERBATIM badge, a line-number
gutter and a Copy button whose clipboard content equals those bytes.
The phase's UI contract (PLAN §7.1 + AGENTS rule 5): an answer's code
block is EITHER verbatim-and-badged OR plainly the model's — never a
silently-substituted hybrid, which is why `.verbatim-badge` belongs to a
resolved panel alone and `.code-block` (the plain wrapper task 07 adds
around an ordinary `<pre>`) carries the Copy button and no badge.
PHASE-08 TOKENS ONLY — no hex/rgb/hsl literal anywhere in this block (the
phase-92 monochrome invariant, and the :root-drift test parses the
built-ins out of this file, so a literal would be a second source of
truth). A monochrome or inverted theme keeps every state readable because
the states are carried by TEXT and STRUCTURE, never by colour alone
(WCAG 1.4.1): `Copy` → `Copied` is a text change with the --ok family as
support, the badge is the WORD "VERBATIM", the unresolved state is a
sentence, and the line gutter is digits. Contrast of the pairs used here
(built-in palette, all AA ≥4.5:1):
--ink on --surface 15.3:1 (the code text)
--ink-soft on --surface 8.2:1 (caption, gutter, pending state, button label)
--ok-ink on --ok-bg 10.6:1 (the badge, the copied state's fill)
--ok-ink on --surface 12.2:1 (the copied state's label on hover-off)
--ink-soft on --err-bg 7.9:1 (the unresolved sentence)
A code surface is also readable in a LIGHT theme for the same reason: no
#fff/#000, no new hue — every token comes from the theme's own palette.
Static content: NOTHING in this block animates or transitions, so
prefers-reduced-motion has nothing to still (the pending state is a
`role="status"` live region — the announcement IS the feedback, a pulse
would add nothing but motion). */
.verbatim {
display: block;
min-width: 0;
margin: 0.5rem 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow: hidden;
}
/* The head row is the panel's sticky header — PLAN §7.1's bounded-panel
rule (the .table-wrap table thead th idiom, phase 156): it stays put
while the body scrolls, so the caption (source/path · lines 12–40 of 210)
is never off-screen in a 40-line slice. OPAQUE --surface background, so
scrolled bytes never show through it. */
.verbatim-head {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
/* The clipped label keeps the phase-156 `.cell-clip` ellipsis convention
(block/flex item that shrinks below its text width, hard cut → "…"); the
FULL text stays reachable through the control's aria-label / title, so
the clip is visual only. */
.verbatim-caption {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
font-size: 0.75rem;
color: var(--ink-soft); /* 8.2:1 on --surface */
}
/* The badge is a WORD, never an icon alone (locked A5: the VERBATIM claim
must survive a screen reader and a monochrome theme), and
`text-transform: none` keeps the lowercase letters the clipboard/AT
would otherwise have to reconcile (no uppercase surprises spelled out
letter-by-letter). --ok-ink on --ok-bg is the app's existing AA pair. */
.verbatim-badge {
flex: 0 0 auto;
padding: 0.1rem 0.45rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--ok-bg);
color: var(--ok-ink); /* 10.6:1 on --ok-bg */
font-family: var(--mono);
font-size: 0.68rem;
text-transform: none;
}
/* The bounded scroll panel (PLAN §7.1: a bounded panel, never a page-long
paste). CODE scrolls sideways and never wraps (phase 116 locked A1 —
wrapping breaks code readability), so overflow-wrap is pinned back to
normal against the .bubble's `overflow-wrap: anywhere` (the phase-165
prose/code split: prose wraps, code scrolls). min-width: 0 keeps the
long-line escape hatch that every other scroller in the app carries. */
.verbatim-body {
max-height: min(40rem, 70vh);
overflow: auto;
min-width: 0;
margin: 0;
padding: 0.5rem 0.75rem;
background: var(--surface);
color: var(--ink); /* 15.3:1 on --surface */
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.5;
white-space: pre;
tab-size: 2;
overflow-wrap: normal;
}
/* No scrollbar-color here ON PURPOSE: phase 116 scoped the themed
scrollbar to `.doc-modal .doc-raw` alone (pinned by
tests/unit/test_prose_word_wrap.py — "no global scrollbar restyle"), and
the chat's other scrollers (.bubble pre, .md-table-wrap) keep the
browser-native bar. The verbatim panel joins THOSE, not the modal. */
/* The gutter: the SERVER's line numbers (the phase-131 coordinate system —
the same 1-based lines `read` and `grep` report), never the model's
claim. `user-select: none` is the load-bearing rule: a reader who
selects the text — or hits Copy on a panel whose selection the browser
picks up — never drags line numbers into the clipboard. The digits are
rendered as list-item TEXT (list-style is off) so the rule behaves the
same in every engine, tabular-nums keeps the column straight, and the
gutter sticks to the left edge while the code scrolls under it. */
.verbatim-lines {
list-style: none;
position: sticky;
left: 0;
flex: 0 0 auto;
min-width: 2.5rem;
background: var(--surface); /* opaque: scrolled code never shows THROUGH
the gutter (the .table-wrap thead rule) */
margin: 0;
padding: 0 0.6rem 0 0;
text-align: right;
color: var(--ink-soft); /* 8.2:1 on --surface — a gutter is still text */
font-variant-numeric: tabular-nums;
user-select: none;
-webkit-user-select: none;
}
.verbatim-lines li {
user-select: none;
-webkit-user-select: none;
}
/* Neutralize the .bubble pre treatment INSIDE the panel: the panel is its
own surface (its own padding, hairline, scroll), so a <pre> child must
not stack a second border/padding on top of it. */
.verbatim pre {
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
/* The streaming/pending state (markdown.js step 0 → the restore step) and
the settled-but-unresolvable state. The pending look is MUTED on purpose
— it is not an answer yet — and it never paints the model's bytes. */
.verbatim-pending {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.15rem 0.6rem;
padding: 0.45rem 0.6rem;
background: var(--surface);
}
.verbatim-state {
min-width: 0;
color: var(--ink-soft); /* 8.2:1 on --surface */
font-size: 0.78rem;
}
/* The honest failure state (locked A7): a locator the server could not
resolve says so in a sentence, carries NO badge and NO body, and shows
none of the model's text. --err-line is the app's existing UI boundary
token (decorative, 4.6:1 — the sentence, not the colour, carries the
meaning); the badge rule below is CSS-side enforcement of the claim that
an unresolved panel never badges, whatever DOM reaches it. */
.verbatim-unresolved {
padding: 0.45rem 0.6rem;
background: var(--err-bg);
border-color: var(--err-line);
color: var(--ink-soft); /* 7.9:1 on --err-bg */
font-size: 0.8rem;
}
.verbatim-unresolved .verbatim-badge { display: none; }
/* The two Copy controls — `.verbatim-copy` inside a resolved panel's head,
`.code-block-copy` under an ordinary answer code block (locked A5: every
fenced block gets a Copy button, only a resolved one gets the badge).
The house pill family (mono text, --line hairline, --surface fill,
ink-soft label 8.2:1); the label is the designated clip target
(text-overflow: ellipsis, the phase-156 convention) while the control's
aria-label names the full path and line count. `min-height: 2rem` is the
desktop pill; the ≤640px block below lifts both to the 44px touch floor,
and the global :focus-visible ring is restated with the SAME values
(explicit here because the panel clips with overflow: hidden). */
.code-block {
position: relative;
min-width: 0;
margin: 0.4rem 0;
}
.code-block pre { margin: 0; }
.code-block-copy,
.verbatim-copy {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 100%;
min-width: 0;
min-height: 2rem;
padding: 0.25rem 0.65rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-soft); /* 8.2:1 on --surface */
font-family: var(--mono);
font-size: 0.72rem;
cursor: pointer;
}
.code-block-copy > span,
.verbatim-copy > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.code-block-copy:hover,
.verbatim-copy:hover { color: var(--ink); border-color: var(--ink-soft); }
.code-block-copy:focus-visible,
.verbatim-copy:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* The `.copied` state: the TEXT changes ("Copy" → "Copied", task 07 owns
the swap) and this rule SUPPORTS it with the --ok family — colour is
never the only signal (WCAG 1.4.1), so a monochrome theme still reads
"Copied". */
.code-block-copy.copied,
.verbatim-copy.copied {
color: var(--ok-ink); /* 10.0:1 on --surface */
border-color: var(--ok-ink);
background: var(--ok-bg);
}
/* A resolved panel's Copy pill sits at the END of the head row (the caption
already took the flexible space; the auto margin keeps it right even
when the caption clips). */
.verbatim-copy { margin-left: auto; flex: 0 0 auto; }
.code-block-copy { margin: 0.25rem 0 0; }
/* What task 07's REAL DOM needs on top of task 06's contract — palette
variables only, no new literal, nothing animated. The body is a ROW of
(gutter, text): the gutter's `flex: 0 0 auto` + `position: sticky;
left: 0` above are written for exactly this, and the text takes its own
width so a long line scrolls UNDER the sticky gutter instead of wrapping
(phase 116 locked A1). */
.verbatim-body { display: flex; align-items: flex-start; }
.verbatim-text { flex: 0 0 auto; }
/* The panel's link to the document viewer (the chat page opens the modal,
the shared page navigates — one affordance, one look). It is a TEXT link
(underlined, not colour-only — WCAG 1.4.1) in the mono family of the
caption it sits beside, and it restates the app-wide focus ring with the
SAME values because the panel clips with `overflow: hidden`. */
.verbatim-open {
flex: 0 0 auto;
color: var(--ink-soft); /* 8.2:1 on --surface */
font-family: var(--mono);
font-size: 0.72rem;
text-decoration: underline;
text-underline-offset: 2px;
}
.verbatim-open:hover { color: var(--ink); }
.verbatim-open:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* The truncation line of a capped block: one muted sentence naming that the
rest lives in the document, plus the same link. It sits OUTSIDE the scroll
body, so it stays readable without scrolling the bytes. */
.verbatim-more {
padding: 0.4rem 0.6rem;
border-top: 1px solid var(--line);
background: var(--surface);
color: var(--ink-soft); /* 8.2:1 on --surface */
font-size: 0.75rem;
}
/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */
.msg.user { justify-content: flex-end; }
.msg.user .msg-body { align-items: flex-end; }
.msg.user .bubble {
@@ -6672,6 +6955,17 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
flex-shrink: 0 would keep each at 100%); the "Page N of M"
status sits between them (the 0.75rem gap goes vertical). */
.history-pager { flex-direction: column; align-items: center; }
/* Phase 182 (task 06): the two Copy controls of a verbatim panel and of
an ordinary answer code block reach the 44px touch floor on a phone
(the desktop pill is 32px tall — fine for a pointer, too small a tap
target for a thumb). The pill itself grows; its label keeps the
ellipsis escape, so a long "Copy 210 lines from …" never widens the
panel. */
.code-block-copy, .verbatim-copy { min-height: 44px; }
/* Phase 182 (task 07): the panel's viewer link is a control too — it
reaches the same touch floor (inline-flex so the line box grows without
moving the baseline of the caption row it sits in). */
.verbatim-open { min-height: 44px; display: inline-flex; align-items: center; }
}
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
+531
View File
@@ -0,0 +1,531 @@
/* Brain of Reese — the phase-182 VERBATIM panel client (task 07).
*
* A ` ```bor <source/path>#12-40 ` locator fence is a CLAIM about a file,
* never its contents: `markdown.js` (task 06) drops the model's body and
* paints a `.verbatim.verbatim-pending` placeholder keyed by the canonical
* locator, and the SERVER (`app.rag.verbatim.resolve_answer_blocks`,
* task 05) puts the real bytes on the `done` frame as `answer_text`. This
* file is the other half of that handshake: it RESOLVES the placeholders
* against the frame's blocks (`hydrateVerbatim`), places the blocks the
* answer never located (`appendUnlocatedVerbatim`), settles leftovers when
* a turn ends without a `done` (`settleVerbatimPlaceholders`), gives every
* settled answer code block a Copy button (`enhanceAnswerCodeBlocks`) and
* owns the ONE clipboard path (`copyTextWithFallback`).
*
* WHY IT IS A CLASSIC SCRIPT, NOT A MODULE (the `markdown.js` pattern):
* `index.html` and `shared.html` load it as a plain `<script>` right after
* `markdown.js` and before their page modules, so the top-level function
* declarations below are globals both module pages can call — the ONE
* implementation shared by the live turn, the reload restore and the
* anonymous shared page (parity is the phase's hard requirement). The
* Containerfile minifies it WITHOUT `--bundle` for the same reason.
*
* NO LOCATOR GRAMMAR LIVES HERE (the phase's main drift risk): the canonical
* string is produced in exactly ONE client place,
* `markdown.js::borLocator` (the mirror of Python
* `app.rag.verbatim.canonical_locator`). Hydration reads the already
* normalized `data-bor-locator` attribute and compares it to the server's
* `locator` with a plain string `===`. Nothing here re-parses a range,
* canonicalizes an en dash, or normalizes a path.
*
* THE CONTRACT WITH THE SERVER (LOCKED A2/A4/A7):
* • `block.text` is painted with `textContent` and copied with
* `navigator.clipboard` VERBATIM — no trim, no re-wrap, no join, no
* model byte ever substituted in, and the line numbers never enter the
* clipboard (they live in the `user-select: none` gutter, a separate
* node the copy path never reads);
* • the caption's numbers are the SERVER's resolved extent, so a locator
* whose range the model got wrong shows what the server actually read
* (A2 made visible);
* • a locator the server could not resolve says so — the unresolved node
* carries NO badge, NO body and NONE of the model's bytes.
*
* The house frontend rules hold throughout: every node is built with
* `createElement` + `textContent` (no HTML string anywhere — the data here
* is model-written text and indexed file bytes), every handler is attached
* with `addEventListener` (the CSP is `default-src 'self'`: no inline
* handlers, no `onclick=`/`oncopy=` attribute), and the states are carried
* by TEXT and STRUCTURE, never by colour alone (WCAG 1.4.1).
*/
/* The one honest sentence for a locator nothing resolved (the pinned copy —
* the whole unresolved state is this sentence plus the document link, so a
* screen reader, a monochrome theme and a screenshot all agree on it). */
function verbatimUnresolvedCopy() {
return "I could not verify this against the knowledge base";
}
/* The badge's claim, spelled out for the pointer tooltip — the WORD is the
* signal (locked A5), the tooltip only explains it. */
function verbatimBadgeTitle() {
return "These bytes are copied from the knowledge base, not written by the model.";
}
/* ---------- the small guards (a malformed frame never throws) ----------
* `answer_text` crosses the wire, so every field is treated as untrusted
* shape: a block that is not a plain object with the three fields the panel
* cannot live without is DROPPED, exactly like the restore path drops a
* malformed timeline entry. */
function isVerbatimBlock(block) {
return !!(
block &&
typeof block === "object" &&
typeof block.source === "string" &&
block.source &&
typeof block.path === "string" &&
block.path &&
typeof block.text === "string"
);
}
function verbatimBlocks(blocks) {
return Array.isArray(blocks) ? blocks.filter(isVerbatimBlock) : [];
}
/* A positive line count, or null (a missing/garbage count never prints). */
function verbatimCount(value) {
return Number.isInteger(value) && value > 0 ? value : null;
}
/* The one `source/path` identity the whole app uses (the read/grep
* coordinate). NO normalization here — see the header: markdown.js owns the
* grammar, this only joins the two halves the server already resolved. */
function verbatimIdentity(source, path) {
return `${source}/${path}`;
}
/* Split a canonical locator into its identity half and its range suffix.
* The split is on the `#` the server PUT THERE (canonical_locator's own
* separator) — never a re-parse of the range. */
function verbatimSplitLocator(locator) {
const text = typeof locator === "string" ? locator : "";
const hash = text.indexOf("#");
return hash === -1
? { identity: text, hasRange: false }
: { identity: text.slice(0, hash), hasRange: true };
}
/* The plain viewer href (the shared page's whole affordance — that page has
* no modal, and the documents API is admin-gated so a guest lands on the
* sign-in page by design). app.js OVERRIDES this through
* `opts.documentHref` with `documentUrl(…, "/")`, the chip's own
* back-to-the-chat URL (phase 153). A rooted path built from
* encodeURIComponent — never a `javascript:`/`data:` target. */
function verbatimDocumentHref(source, path) {
return (
"/document.html?source=" +
encodeURIComponent(source) +
"&path=" +
encodeURIComponent(path)
);
}
/* ---------- the panel's text pieces ---------- */
/* `homelab/a.md · lines 12–40 of 210`, or `homelab/a.md · all 210 lines`
* when the block IS the whole document (the caption is the SERVER's extent:
* `start_line`/`end_line` describe exactly the lines `text` contains after
* any character cap, so a capped block reads as a range, never as "all"). */
function verbatimCaptionText(block) {
const identity = verbatimIdentity(block.source, block.path);
const start = verbatimCount(block.start_line);
const end = verbatimCount(block.end_line);
const total = verbatimCount(block.total_lines);
if (start === null || end === null || end < start) return identity;
if (!block.truncated && start === 1 && total !== null && end === total) {
return `${identity} · all ${total} lines`;
}
return total === null
? `${identity} · lines ${start}–${end}`
: `${identity} · lines ${start}–${end} of ${total}`;
}
/* `Verbatim text from homelab/a.md, lines 12–40` (the range clause only when
* the panel shows a range — a whole-document panel is named by its file). */
function verbatimPanelLabel(block) {
const identity = verbatimIdentity(block.source, block.path);
const start = verbatimCount(block.start_line);
const end = verbatimCount(block.end_line);
if (start === null || end === null || end < start) {
return `Verbatim text from ${identity}`;
}
if (start === end) return `Verbatim text from ${identity}, line ${start}`;
return `Verbatim text from ${identity}, lines ${start}–${end}`;
}
/* The lines the gutter numbers: one entry per LINE OF THE SERVER TEXT, each
* `{ shown, number }`. A truncated block's last line is the server's
* `[…truncated…]` marker — a real line of the panel, but NOT a line of the
* file, so it gets no number (an empty cell keeps the rows aligned; the
* phase's rule is that the gutter never claims a coordinate the file does
* not have). */
function verbatimGutter(block) {
const lines = String(block.text).split("\n");
const start = verbatimCount(block.start_line);
const end = verbatimCount(block.end_line);
const numbered = start !== null && end !== null && end >= start ? end - start + 1 : 0;
return lines.map((_, i) => ({
number: i < numbered ? String(start + i) : "\u00a0",
}));
}
/* ---------- the controls ---------- */
/* The select-on-focus of the clipboard fallback field (an `<a>` has no
* `.select()`, a range does the job). Best-effort: a selection failure only
* means the reader copies by hand — the field still shows the whole text.
* (app.js's `copyShareLinkWithFallback` used to own this; task 07 moved the
* pair into verbatim.js so the code blocks and the share link share ONE
* degradation.) */
function selectAllInFallbackField(el) {
try {
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} catch {
/* selection is best-effort — the field still carries the full text */
}
}
/* The phase-55 degradation, generalized (LOCKED: the owner-locked share
* strings and behaviour are unchanged — app.js delegates here).
* `navigator.clipboard` is unavailable on a non-secure (http) homelab origin
* and rejects outside a user gesture, so the failure path renders ONE
* transient link field inside `hostEl` that selects its text on focus
* (click or Tab, then Ctrl/Cmd+C). Returns true when the clipboard took it.
* NEVER reads the DOM — the caller hands over the exact string, which is
* what makes "the clipboard content equals the server text" true. */
async function copyTextWithFallback(text, hostEl, title) {
const host = hostEl || document.body;
document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove());
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const field = document.createElement("a");
field.className = "share-link-fallback";
// An <a> needs an href to focus; a code block has none, so it takes the
// tabindex instead and stays a select-to-copy field either way.
if (/^(?:https?:|mailto:|\/)/i.test(text)) field.href = text;
else field.tabIndex = 0;
field.textContent = text; // the text is data — textContent, never innerHTML
field.title = title || "Click, then copy (Ctrl/Cmd+C)";
field.addEventListener("focus", () => selectAllInFallbackField(field));
host.appendChild(field); // beside the control that failed, never elsewhere
field.focus({ preventScroll: true });
return false;
}
}
/* One pill: `Copy` → `Copied` (the TEXT carries the state; `.copied` only
* supports it — WCAG 1.4.1). The label rides a `<span>` because the clipped
* label is the CSS ellipsis target (the phase-156 rule) while the control's
* `aria-label` names the full path. Sticky by design: no timer, so a reader
* who tabs away and back finds the state the click left. */
function buildCopyButton(className, text, ariaLabel, hostEl) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = className;
btn.setAttribute("aria-label", ariaLabel);
const label = document.createElement("span");
label.textContent = "Copy";
btn.appendChild(label);
btn.addEventListener("click", () => {
copyTextWithFallback(text, hostEl || btn.parentElement, ariaLabel).then((ok) => {
if (!ok) return; // the field is the feedback (it is focused and selected)
btn.classList.add("copied");
label.textContent = "Copied";
});
});
return btn;
}
/* The panel's link to the document. `opts.onOpen(source, path, linkEl)` is
* the chat page's modal hook (phase 26/153 — left-click opens the viewer IN
* the page, the href stays the `/document.html` escape hatch for
* middle-click and copy-link); with no hook the href simply navigates,
* which is the shared page's whole affordance. */
function buildVerbatimOpenLink(source, path, opts, text) {
const link = document.createElement("a");
link.className = "verbatim-open";
const identity = verbatimIdentity(source, path);
const href =
opts && typeof opts.documentHref === "function"
? opts.documentHref(source, path)
: verbatimDocumentHref(source, path);
link.href = href;
link.textContent = text || identity;
link.title = identity; // the full path as the native tooltip (chip pattern)
if (opts && typeof opts.onOpen === "function") {
link.addEventListener("click", (event) => {
event.preventDefault(); // no new tab — the modal takes over
event.stopPropagation();
opts.onOpen(source, path, link);
});
}
return link;
}
/* ---------- the panel ---------- */
/* buildVerbatimPanel(block, opts) → the `.verbatim` node.
*
* .verbatim[role=group][aria-label][data-bor-locator]
* .verbatim-head sticky (PLAN §7.1)
* .verbatim-caption source/path · lines 12–40 of 210 (clipped)
* .verbatim-badge VERBATIM (a WORD)
* .verbatim-open → the document viewer (optional)
* .verbatim-copy Copy → Copied (aria-label
* names path)
* .verbatim-body the bounded scroll panel
* ol.verbatim-lines the SERVER's line numbers (user-select: none)
* pre.verbatim-text block.text, byte for byte
* .verbatim-more the truncation line + the link (optional)
*
* `opts = { documentHref, onOpen }` — both optional (see the two builders
* above). Every value reaches the DOM through `textContent`/`title`/a
* built `href`: the caption carries indexed text and the aria-labels carry
* model-written locators, so there is no HTML string to escape. */
function buildVerbatimPanel(block, opts) {
const identity = verbatimIdentity(block.source, block.path);
const root = document.createElement("div");
root.className = "verbatim";
root.setAttribute("role", "group");
root.setAttribute("aria-label", verbatimPanelLabel(block));
// The SERVER's canonical locator is the identity of the placed block (the
// pending placeholder's own attribute is replaced with it — that is how
// `appendUnlocatedVerbatim` knows this block already has a home).
root.dataset.borLocator =
typeof block.locator === "string" && block.locator
? block.locator
: identity;
const head = document.createElement("div");
head.className = "verbatim-head";
const caption = document.createElement("span");
caption.className = "verbatim-caption";
caption.textContent = verbatimCaptionText(block);
caption.title = verbatimCaptionText(block); // the clipped text stays reachable
const badge = document.createElement("span");
badge.className = "verbatim-badge";
badge.textContent = "VERBATIM";
badge.title = verbatimBadgeTitle();
head.append(caption, badge);
head.appendChild(buildVerbatimOpenLink(block.source, block.path, opts));
const gutter = verbatimGutter(block);
// The clipboard's line count (the aria-label's N): the SERVER's extent,
// not the marker line the cap appended.
const from = verbatimCount(block.start_line);
const to = verbatimCount(block.end_line);
const shown = from !== null && to !== null && to >= from ? to - from + 1 : gutter.length;
head.appendChild(
buildCopyButton(
"verbatim-copy",
block.text,
`Copy ${shown} lines from ${identity}`,
root
)
);
root.appendChild(head);
const body = document.createElement("div");
body.className = "verbatim-body";
const numbers = document.createElement("ol");
numbers.className = "verbatim-lines";
numbers.setAttribute("aria-hidden", "true"); // the range rides the panel's label
for (const row of gutter) {
const li = document.createElement("li");
li.textContent = row.number;
numbers.appendChild(li);
}
const pre = document.createElement("pre");
pre.className = "verbatim-text";
pre.textContent = block.text; // THE POINT OF THE PHASE: the server's bytes
body.append(numbers, pre);
root.appendChild(body);
if (block.truncated) {
// One muted line naming where the rest lives, plus the same viewer link
// — the panel never pretends the slice is the file.
const more = document.createElement("div");
more.className = "verbatim-more";
const note = document.createElement("span");
note.textContent = "Cut short by the size limit — the rest is in the document. ";
more.appendChild(note);
more.appendChild(
buildVerbatimOpenLink(block.source, block.path, opts, `open ${identity}`)
);
root.appendChild(more);
}
return root;
}
/* The honest state for a locator the server did not resolve (and for a
* placeholder no terminal frame ever settled): the sentence, plus the
* document link ONLY when the identity actually parsed into a
* `source/path`. NO badge, NO body, NONE of the model's bytes — the fence's
* own text is already gone (task 06 dropped it), so there is nothing here
* to leak even by accident. */
function buildVerbatimUnresolved(locator, opts) {
const root = document.createElement("div");
root.className = "verbatim verbatim-unresolved";
root.setAttribute("role", "note");
const key = typeof locator === "string" ? locator : "";
if (key) root.dataset.borLocator = key;
root.dataset.verbatimState = (opts && opts.kind) || "unverified";
const state = document.createElement("span");
state.className = "verbatim-state";
state.textContent = verbatimUnresolvedCopy();
root.appendChild(state);
const { identity } = verbatimSplitLocator(key);
const slash = identity.indexOf("/");
if (slash > 0 && slash < identity.length - 1) {
root.appendChild(document.createTextNode(" — open "));
root.appendChild(
buildVerbatimOpenLink(identity.slice(0, slash), identity.slice(slash + 1), opts)
);
}
return root;
}
/* ---------- hydration ---------- */
/* The ONE match rule (LOCKED A2 made visible):
* 1. the canonical locator, compared as PLAIN STRINGS against the
* server's `locator` — the attribute markdown.js already normalized;
* 2. failing that, exactly ONE block sharing the same `source/path`
* (the model's RANGE was wrong; the panel shows the server's extent);
* 3. failing that, null → the unresolved state. An ambiguous identity
* (two blocks, same file, different ranges) never guesses. */
function matchVerbatimBlock(blocks, key) {
for (const block of blocks) {
if (block.locator === key) return block;
}
const identity = verbatimSplitLocator(key).identity;
const samePath = blocks.filter(
(block) => verbatimIdentity(block.source, block.path) === identity
);
return samePath.length === 1 ? samePath[0] : null;
}
/* hydrateVerbatim(root, blocks, opts) — replace every PENDING placeholder
* under `root` with its panel, or with the unresolved state.
*
* Only `.verbatim-pending` is touched, so the pass is idempotent: a panel
* this function built (or one restored from storage) is skipped on a re-run,
* which keeps a `Copied` state and a scrolled body alive. Never throws —
* a missing/malformed `blocks` settles every placeholder to the honest
* state instead. Returns the number of placeholders settled. */
function hydrateVerbatim(root, blocks, opts) {
if (!root || typeof root.querySelectorAll !== "function") return 0;
const list = verbatimBlocks(blocks);
let settled = 0;
root.querySelectorAll(".verbatim-pending[data-bor-locator]").forEach((node) => {
const key = node.getAttribute("data-bor-locator") || "";
const block = matchVerbatimBlock(list, key);
node.replaceWith(
block ? buildVerbatimPanel(block, opts) : buildVerbatimUnresolved(key, opts)
);
settled += 1;
});
return settled;
}
/* appendUnlocatedVerbatim(wrap, blocks, opts) — the blocks NO placeholder
* matched (a `show_text` call whose answer never located the file), appended
* in the frame's order at the `answer_image` position: below the answer
* text, above the source clusters (the phase-124/130 DOM order — callers run
* this BEFORE `appendSources`).
*
* State-free idempotence: a block is already placed when the wrap holds a
* resolved panel carrying its canonical `locator`, so calling this twice
* (or after a hydration that matched) appends nothing. */
function appendUnlocatedVerbatim(wrap, blocks, opts) {
if (!wrap || typeof wrap.querySelectorAll !== "function") return 0;
const list = verbatimBlocks(blocks);
if (!list.length) return 0;
const host = wrap.querySelector(".msg-body") || wrap;
const placed = new Set();
wrap.querySelectorAll(".verbatim[data-bor-locator]").forEach((el) => {
// An unresolved placeholder is NOT a placement (it names a locator the
// server never returned, and the block belongs on screen anyway).
if (el.classList.contains("verbatim-unresolved")) return;
placed.add(el.getAttribute("data-bor-locator"));
});
let appended = 0;
for (const block of list) {
const key =
typeof block.locator === "string" && block.locator
? block.locator
: verbatimIdentity(block.source, block.path);
if (placed.has(key)) continue;
host.appendChild(buildVerbatimPanel(block, opts));
placed.add(key);
appended += 1;
}
return appended;
}
/* settleVerbatimPlaceholders(root, kind) — the terminal-frame sweep the
* failure paths run (the `error` frame, the 300 s guard, the Stop action,
* the failed-turn funnel): a panel NEVER sits at "Retrieving the exact
* text…" once a turn is over. `kind` is recorded on the node
* (`data-verbatim-state`) for the next reader; every kind renders the same
* honest sentence, because "I could not verify this" is the truth for a
* turn that ended before `done` too. */
function settleVerbatimPlaceholders(root, kind) {
if (!root || typeof root.querySelectorAll !== "function") return 0;
let settled = 0;
root.querySelectorAll(".verbatim-pending").forEach((node) => {
node.replaceWith(
buildVerbatimUnresolved(node.getAttribute("data-bor-locator") || "", { kind })
);
settled += 1;
});
return settled;
}
/* ---------- the ordinary answer code block ---------- */
/* enhanceAnswerCodeBlocks(root) — every `<pre>` inside an answer bubble that
* is NOT inside a `.verbatim` gets the `.code-block` wrapper and a Copy
* button, ONCE the turn settled (the callers run it on the settled answer,
* never mid-stream). NO badge, NO caption: a `VERBATIM` claim belongs only
* to a server-resolved block (LOCKED A5) — plainly the model's, or verbatim
* and badged, never a hybrid.
*
* Idempotent on re-render: a `<pre>` already inside a `.code-block` is left
* alone (the live path re-runs the renderer per delta; the restore path runs
* this once). Returns the number of blocks wrapped. */
function enhanceAnswerCodeBlocks(root) {
if (!root || typeof root.querySelectorAll !== "function") return 0;
const bubbles = [];
if (root.classList && root.classList.contains("bubble")) bubbles.push(root);
root.querySelectorAll(".bubble").forEach((el) => bubbles.push(el));
let wrapped = 0;
for (const bubble of bubbles) {
bubble.querySelectorAll("pre").forEach((pre) => {
if (pre.closest(".verbatim")) return; // the panel owns its own Copy
const parent = pre.parentElement;
if (!parent || parent.classList.contains("code-block")) return;
const box = document.createElement("div");
box.className = "code-block";
parent.insertBefore(box, pre);
box.appendChild(pre);
box.appendChild(
buildCopyButton(
"code-block-copy",
pre.textContent || "",
"Copy this code block",
box
)
);
wrapped += 1;
});
}
return wrapped;
}
+6
View File
@@ -1695,6 +1695,12 @@
identical no-op for the default name). -->
<script src="assets/brand.js"></script>
<script src="assets/markdown.js"></script>
<!-- Phase 182 (task 07): the verbatim panel client — a CLASSIC script
immediately AFTER markdown.js (it hydrates the placeholders that
renderer emits, so it must be a global before app.js evaluates) and
before the module scripts. No bundle, no import — same shape as
markdown.js. -->
<script src="assets/verbatim.js"></script>
<!-- Phase 19: the shared header module loads through the page script's
own `import "./header.js"` — a hoisted import that is evaluated
before the page script body calls initSharedHeader() at boot. -->
+13 -1
View File
@@ -187,7 +187,15 @@
evaluated before the page script body calls initSharedHeader()
at boot (no direct header.js <script> tag — single-evaluation
design). NO modal overlay, NO composer, NO Save/Share/
Retry/Tune markup anywhere (owner-locked: zero controls). -->
Retry/Tune markup anywhere (owner-locked: zero controls).
Phase 182 (task 07) is the ONE owner-directed exception, and it is
narrow: a shared answer's verbatim panel and its ordinary code blocks
carry a Copy button and a plain `/document.html?source=&path=` link,
because a shared page that shows a slice of a file has to let the
reader copy and check it. Both come from the SHARED panel client
(verbatim.js — the same globals the chat page calls, so a shared turn
renders pixel-identically); this page still builds no control of its
own, wires no modal, and keeps every chip a plain span. -->
<!-- ABSOLUTE asset paths on purpose: the page is served from the
NESTED route /shared/<token> (not a root-level .html), so a
relative "assets/…" ref would resolve to /shared/assets/… and
@@ -196,6 +204,10 @@
works with absolute refs too. -->
<script src="/assets/brand.js"></script>
<script src="/assets/markdown.js"></script>
<!-- Phase 182 (task 07): the verbatim panel client, AFTER markdown.js
(absolute path like its neighbours — the nested /shared/<token> route
breaks a relative ref). -->
<script src="/assets/verbatim.js"></script>
<script type="module" src="/assets/shared.js"></script>
</body>
</html>
+13
View File
@@ -100,6 +100,19 @@ os.environ["BOR_IMAGE_DIR"] = str(_Settings.model_fields["image_dir"].default)
# ``answer_image`` done-frame key absent) must see the code default.
os.environ["BOR_ANSWER_IMAGES"] = str(_Settings.model_fields["answer_images"].default)
# Phase 182: the same leak class for the verbatim-display settings (the
# dev environment runs the feature ON or tunes the budgets), and the
# grounded tool-OFFER now depends on ``answer_text`` — the offer-matrix
# pins (unit + integration) and the phase-124/182 byte-identity pins
# must see the code defaults regardless of the operator's shell.
os.environ["BOR_ANSWER_TEXT"] = str(_Settings.model_fields["answer_text"].default)
os.environ["BOR_SHOW_TEXT_MAX_CHARS"] = str(
_Settings.model_fields["show_text_max_chars"].default
)
os.environ["BOR_SHOW_TEXT_MAX_BLOCKS"] = str(
_Settings.model_fields["show_text_max_blocks"].default
)
# Phase 140: the same leak class for the pictorial-PDF toggle +
# threshold — an operator's shell may carry BOR_PDF_PICTORIAL /
# BOR_PDF_PICTORIAL_MIN_CHARS (the owner runs the feature on in
+12
View File
@@ -144,6 +144,18 @@ def app_server(mock_llm: int) -> Iterator[str]:
# into the app under test (the phase-61 leak-guard pattern).
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
# Phase 182: the same leak guard for the verbatim-display settings —
# the grounded tool OFFER now depends on ``answer_text``, so every
# module's offer pins must see the CODE default unless the module's
# own app opts in/out explicitly (a module overrides these in its
# own ``env[...]`` assignment, which runs after this builder).
env["BOR_ANSWER_TEXT"] = str(_Settings.model_fields["answer_text"].default)
env["BOR_SHOW_TEXT_MAX_CHARS"] = str(
_Settings.model_fields["show_text_max_chars"].default
)
env["BOR_SHOW_TEXT_MAX_BLOCKS"] = str(
_Settings.model_fields["show_text_max_blocks"].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
+402 -2
View File
@@ -662,7 +662,10 @@ Implements just enough of the aipi surface:
- user message containing ``show me the picture of``
(``SHOW_IMAGE_TRIGGER``, phase 124 task 07 — the image-chips-and-
answers suite ``tests/e2e/test_answer_images.py``) **and** the
system prompt contains ``show_image`` (the tool clause's presence
system prompt carries the show_image clause's marker
(:data:`_SHOW_IMAGE_CLAUSE_MARKER` — a sentence only the image
clause carries, so the phase-182 ``show_text`` clause's pointer to
``show_image`` never trips it; the tool clause's presence
— the ``BOR_ANSWER_IMAGES`` toggle's server-side marker: the
clause rides inside the HIGH prompt's ``<tools>`` section ONLY
when the toggle is on, so on a default-OFF app the trigger NEVER
@@ -691,6 +694,62 @@ Implements just enough of the aipi surface:
phrase — the phase-71/72/94/118 ordering convention); verified
2026-09-26: no existing E2E question or fixture file contains the
phrase, so every other suite is unaffected.
- user message containing ``show me the exact text of``
(``SHOW_TEXT_TRIGGER``, phase 182 task 08 — the verbatim-display suite
``tests/e2e/test_verbatim_show_text.py``) **and** the system prompt
carries the show_text clause's marker
(:data:`_SHOW_TEXT_CLAUSE_MARKER` — a sentence only the TEXT clause
carries, the exact mirror of ``_SHOW_IMAGE_CLAUSE_MARKER``, so a
``BOR_ANSWER_TEXT=0`` app never fires it) -> the deterministic
SHOW_TEXT flow, discriminated statelessly from the tool results
(streaming only). The question carries the scripted target — a
combined ``source/path`` with an optional ``12-40`` line range and an
optional ``describe it without a block`` marker
(``SHOW_TEXT_NO_FENCE_MARKER``):
* request 1 (``tools`` offered, no tool result yet): stream ONLY
``tool_calls`` deltas — ``show_text`` with ``{"path": <target>}``
plus ``offset``/``limit`` when the question named a range
(synthetic id ``call_show_text``); the target comes from the
QUESTION (not the seed), which is what lets one trigger drive a
text doc, an image doc (the LOCKED-A6 refusal) and an
out-of-range request;
* the follow-up request (any ``tool``-role result — a refusal means
"stop calling" too): the FINAL answer — prose, one
`` ```bor <locator> `` fence whose BODY is a DELIBERATELY
CORRUPTED copy of the slice the tool returned (one flipped
character + a trailing space via
:func:`corrupt_verbatim_line`, plus a line carrying
``SHOW_TEXT_MODEL_BODY_MARKER``), then a SECOND standalone
`` ```bor `` fence for the OTHER seeded text document when the show
SUCCEEDED (the answer-order half of the contract; a refused turn
stays a single-fence answer so the honest-state cases are clean), then
one ordinary
`` ```bash `` block (the model's own, A5's Copy control) — unless
the question carried ``SHOW_TEXT_NO_FENCE_MARKER``, which drops
every fence so the shown block lands UNLOCATED (the client's
append path, above ``.sources``).
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
phrase — the phase-71/72/94/118 ordering convention); no existing E2E
question or fixture file contains the phrase.
- user message containing ``put the block inline``
(``SHOW_TEXT_INLINE_TRIGGER``, phase 182 task 08, same suite) -> the
deterministic answer with ONE `` ```bor `` locator fence and ZERO tool
calls — the half of the contract the tool does not cover (a fence that
appears in the answer text on its own resolves through the same
server-side pass). Its body is the model's own claim (it never saw the
bytes), so it carries ``SHOW_TEXT_MODEL_BODY_MARKER`` and the suite
asserts none of it painted. NOT clause-gated on purpose: on the
``BOR_ANSWER_TEXT=0`` app the same text lands, which is how that suite
proves the client never paints the model's bytes even with the tool and
the clause gone. Checked BEFORE the ``DEFLECT_MODE`` branch (the marker
lives in the user message — the ``TABLE_TRIGGER`` convention).
- user message containing ``show me a block i cannot find``
(``SHOW_TEXT_MISSING_TRIGGER``, phase 182 task 08, same suite) -> the
deterministic answer whose one `` ```bor `` fence names
``SHOW_TEXT_MISSING_TARGET`` — a locator the server has NO document
for, so the client must land on its honest unresolved state (no badge,
no body, none of the model's bytes). Checked BEFORE the
``DEFLECT_MODE`` branch, same convention.
- user message containing ``which diagram is in your notes``
(``DIAGRAM_TRIGGER``, phase 124 task 07 — the BUG-FIX-ONLY
trigger, same suite) -> the deterministic ZERO-READ answer: the
@@ -2716,6 +2775,20 @@ CONTEXT_LINE_TRIGGER = "quote the image reference"
#: the house marker-flow convention).
_SHOW_CONFIRM_MARKER = "Image shown to the user"
#: The SHOW_IMAGE flow's clause gate (phase 124, task 07) — tightened
#: 2026-10-15 (phase 182, task 04). The gate's INTENT is "the
#: ``show_image`` clause is in the system prompt" (the
#: ``BOR_ANSWER_IMAGES`` toggle's server-side marker) and it was written
#: as the loose substring check ``"show_image" in system``. Phase 182's
#: locked ``SHOW_TEXT_CLAUSE`` ends with "For an image use `show_image`
#: instead.", so that approximation now also matches a text-clause-only
#: prompt and the image flow fired on the default-OFF app (the phase-124
#: toggle-off pin failed). One sentence only the IMAGE clause carries is
#: the exact marker: present iff ``show_image=True``, so the ON app fires
#: exactly as before and a ``BOR_ANSWER_IMAGES=0`` app stays silent even
#: when ``BOR_ANSWER_TEXT`` (default ON) is what mentions the tool.
_SHOW_IMAGE_CLAUSE_MARKER = "displays a standalone image document"
#: The image-doc extension family (``app.rag.summarizer.IMAGE_MIMES``
#: — phase 122's ext→mime map, imported so the mock can never drift
#: from the app's image family): a seeded ``<documents>`` block is
@@ -2800,7 +2873,7 @@ def _show_image_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
if SHOW_IMAGE_TRIGGER not in _user(body).lower():
return None
system = _system(body)
if "show_image" not in system:
if _SHOW_IMAGE_CLAUSE_MARKER not in system:
return None
doc = _seeded_image_doc(system)
if doc is None:
@@ -2812,6 +2885,295 @@ def _show_image_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("call", f"{doc[0]}/{doc[1]}")
# ---------------------------------------------------------------------------
# Phase 182 (task 08): the SHOW_TEXT (verbatim display) flows
# ---------------------------------------------------------------------------
#: A user message containing this substring (case-insensitive) — combined
#: with the ``show_text`` clause's marker (:data:`_SHOW_TEXT_CLAUSE_MARKER`,
#: the ``BOR_ANSWER_TEXT`` toggle's server-side marker, exactly as
#: ``_SHOW_IMAGE_CLAUSE_MARKER`` is phase 124's) — drives the deterministic
#: SHOW_TEXT flow described in the module docstring: the scripted tool call
#: on the first request, the fence-carrying final answer once a tool result
#: exists. The TARGET lives in the question (a combined ``source/path`` +
#: an optional ``12-40`` range + an optional :data:`
#: SHOW_TEXT_NO_FENCE_MARKER`), which is what lets ONE trigger drive a text
#: doc, an image doc (LOCKED A6's refusal) and a wrong-range request.
#: Disjoint from every other trigger phrase — the phase-71/72/94/118
#: ordering convention; no existing E2E question or fixture file contains
#: the phrase, so every other suite is unaffected.
SHOW_TEXT_TRIGGER = "show me the exact text of"
#: In the show-me question: the final answer carries NO locator fence, so
#: the block the tool showed is UNLOCATED by the prose — the client's
#: append path (below the answer text, above ``.sources``, phase 124's DOM
#: order) is what renders it.
SHOW_TEXT_NO_FENCE_MARKER = "describe it without a block"
#: A user message containing this substring (case-insensitive) -> the ONE
#: locator fence with NO tool call at all (see the module docstring). Not
#: clause-gated: the ``answer_text=False`` app receives the same text, so
#: the suite can pin the client's own no-model-bytes rule on the kill-switch
#: build too.
SHOW_TEXT_INLINE_TRIGGER = "put the block inline"
#: A user message containing this substring (case-insensitive) -> the ONE
#: fence naming :data:`SHOW_TEXT_MISSING_TARGET` (a locator with no row).
SHOW_TEXT_MISSING_TRIGGER = "show me a block i cannot find"
#: The hallucinated locator (never seeded — the fixture's sources are
#: ``baselab`` and the suite seeds no file of this name).
SHOW_TEXT_MISSING_TARGET = "baselab/notes/the-file-that-is-not-there.md"
#: A token that exists ONLY in the model's re-typed fence BODY. The suite
#: asserts it appears nowhere in the rendered DOM (the body is dropped at
#: render, :mod:`app.rag.verbatim` supplies the bytes) while the answer's
#: ordinary `` ```bash `` block — legitimately the model's — stays.
SHOW_TEXT_MODEL_BODY_MARKER = "MODEL_RETYPED_THIS_LINE"
#: The ordinary (non-locator) fenced block every phase-182 answer carries —
#: LOCKED A5's control: `.code-block` + Copy, NO badge, flag-independent.
SHOW_TEXT_PLAIN_LANG = "bash"
SHOW_TEXT_PLAIN_BODY = "systemctl --user start gufo.container"
#: The SHOW_TEXT flow's clause gate (phase 182, task 08) — one sentence
#: :data:`app.rag.prompts.SHOW_TEXT_CLAUSE` carries and nothing else does,
#: present iff ``answer_text=True`` (the exact mirror of
#: :data:`_SHOW_IMAGE_CLAUSE_MARKER`, tightened for the same reason).
_SHOW_TEXT_CLAUSE_MARKER = "displays part of a document to the user EXACTLY as stored"
#: The agent's ``show_text`` SUCCESS result's confirming first line
#: (``app.rag.agent``'s ``show_text`` branch — the line ``read``'s result
#: never carries). Kept for the wire assertion; the flow itself stops on
#: ANY tool result, because a REFUSAL also means "do not call again" (the
#: wrong-range and image-doc variants depend on that: the model still
#: answers, and the fence it writes is what the client must judge honestly).
_SHOW_TEXT_CONFIRM_MARKER = "Text shown to the user"
#: The target inside the question: a combined ``source/path`` plus an
#: optional 1-based ``12-40`` / ``lines 12-40`` range — the model's CLAIM.
#: (The server's extent is whatever the resolver returned; the suite pins
#: the two apart.)
_SHOW_TEXT_TARGET_RE = re.compile(
r"(?P<path>[a-z0-9_.\-]+(?:/[a-z0-9_.\-]+)+)"
r"(?:\s+(?:lines?\s+)?(?P<start>\d+)\s*[-\u2013]\s*(?P<end>\d+))?"
)
def corrupt_verbatim_line(line: str) -> str:
"""The model's one-character drift on a real line (phase-182 E2E).
Flips the first ``a`` to ``4`` (or the last character when the line has
no ``a``) and appends a trailing space — exactly the silent drift a
retyping model produces, the incident the phase exists for. Exported so
the suite can derive the SAME string from the fixture's stored text and
assert none of it reached the DOM.
"""
flipped = line.replace("a", "4", 1)
if flipped == line:
flipped = line[:-1] + "4" if line else "4"
return flipped + " "
def _show_text_request(body: dict[str, Any]) -> tuple[str, int | None, int | None]:
"""The scripted target ``(path, claimed_start, claimed_end)``.
Markdown emphasis/backticks are stripped first: the questions quote the
path the way a person types it (``Show me the exact text of
`baselab/a/b.md` 2-999?``), and the closing backtick sits between the
path and its range.
"""
text = _user(body).lower().replace("`", "")
match = _SHOW_TEXT_TARGET_RE.search(text)
if match is None:
return "", None, None
start, end = match.group("start"), match.group("end")
return (
match.group("path"),
int(start) if start else None,
int(end) if end else None,
)
def _show_text_locator(path: str, start: int | None, end: int | None) -> str:
"""The locator the model writes: ``source/path`` or ``source/path#12-40``."""
return f"{path}#{start}-{end}" if start and end else path
def _show_text_slice(result: str) -> str:
"""The verbatim slice a ``show_text`` result carried (everything after its
``lines: a-b of N`` extent line), or ``""`` when the call was refused."""
lines = result.split("\n")
for index, line in enumerate(lines):
if line.startswith("lines: "):
return "\n".join(lines[index + 1 :])
return ""
def _show_text_fence_body(slice_text: str) -> str:
"""The deliberately corrupted copy of the slice + the sentinel line."""
lines = [line for line in slice_text.split("\n") if line.strip()]
if not lines:
lines = ["the model has nothing to retype after a refusal"]
return "\n".join(
[corrupt_verbatim_line(lines[0]), *lines[1:], SHOW_TEXT_MODEL_BODY_MARKER]
)
def _show_text_plain_block() -> str:
"""One ordinary fenced block — the model's own, badged nothing (A5)."""
return f"```{SHOW_TEXT_PLAIN_LANG}\n{SHOW_TEXT_PLAIN_BODY}\n```"
def _second_seeded_document(system: str, exclude: str) -> str:
"""The OTHER text document in the ``<documents>`` seed (combined path),
or ``""`` — the second fence's target (the answer-order half)."""
for block in _DOCUMENT_BLOCK_RE.finditer(system):
identity = f"{block.group('source')}/{block.group('path')}"
if identity == exclude or identity.lower().endswith(_IMAGE_PATH_SUFFIXES):
continue
return identity
return ""
def _show_text_call_arguments(path: str, start: str, end: str) -> dict[str, Any]:
"""The scripted ``show_text`` arguments: ``path`` always, ``offset`` +
``limit`` only when the question named a range (``limit`` derived from
the CLAIMED range — the server clamps it and the caption follows the
server)."""
arguments: dict[str, Any] = {"path": path}
if start:
first = int(start)
last = int(end) if end else first
arguments["offset"] = first
arguments["limit"] = max(last - first + 1, 1)
return arguments
def _show_text_answer(body: dict[str, Any], result: str) -> str:
"""The final answer of the SHOW_TEXT turn (prose + fences + a plain block).
With :data:`SHOW_TEXT_NO_FENCE_MARKER` in the question the answer carries
NO locator fence at all — the block the tool showed then has no home in
the prose, which is the client's APPEND path (below the answer text,
above ``.sources``). The SECOND (other-file) fence rides only a turn whose
show SUCCEEDED (:data:`_SHOW_TEXT_CONFIRM_MARKER`): a refused turn (the
image document, a range past the end) is the suite's honest-state case and
stays a single-fence answer.
"""
path, start, end = _show_text_request(body)
parts = ["Here is the exact text, straight from the file:"]
if SHOW_TEXT_NO_FENCE_MARKER not in _user(body).lower():
parts += [
"",
"```bor " + _show_text_locator(path, start, end),
_show_text_fence_body(_show_text_slice(result)),
"```",
]
second = (
_second_seeded_document(_system(body), path)
if _SHOW_TEXT_CONFIRM_MARKER in result
else ""
)
if second:
parts += [
"",
"And the note file next to it, in full:",
"",
"```bor " + second,
f"The model re-typed this file instead — {SHOW_TEXT_MODEL_BODY_MARKER}.",
"```",
]
else:
parts += [
"",
"It is on screen above — the panel holds it, so it is not repeated here.",
]
parts += [
"",
"To start it afterwards:",
"",
_show_text_plain_block(),
"",
"(Deterministic mock answer for E2E.)",
]
return "\n".join(parts)
def _show_text_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-182 SHOW_TEXT request (see the module docstring).
Stateless over the messages, like the other marker flows:
* ``("call", path, start, end)`` — the trigger is in the question, the
system prompt carries the ``show_text`` clause and no ``tool``-role
result exists yet: stream ONLY the ``show_text`` tool-call delta built
by :func:`_show_text_call_arguments` from the three strings (``""`` =
no range).
* ``("answer", text)`` — a ``tool``-role result is in the messages
(success or refusal — both mean "stop calling"): the fence-carrying
final answer.
* ``None`` — the trigger is absent, the clause is absent (a
``BOR_ANSWER_TEXT=0`` app: the mock behaves exactly as pre-phase), or
the question names no ``source/path`` target.
"""
if SHOW_TEXT_TRIGGER not in _user(body).lower():
return None
if _SHOW_TEXT_CLAUSE_MARKER not in _system(body):
return None
path, start, end = _show_text_request(body)
if not path:
return None
results = _tool_results(body)
if results:
return ("answer", _show_text_answer(body, results[-1]))
return (
"call",
path,
str(start) if start is not None else "",
str(end) if end is not None else "",
)
def show_text_inline_answer(body: dict[str, Any]) -> str:
"""The ``put the block inline`` answer: ONE locator fence, zero tool
calls (see the module docstring)."""
path, start, end = _show_text_request(body)
return "\n".join(
[
"Sure — the block itself:",
"",
"```bor " + _show_text_locator(path, start, end),
f"Run `podman generate quadlet gufo` and paste the inline block "
f"for baselab — {SHOW_TEXT_MODEL_BODY_MARKER}.",
"```",
"",
"To apply it:",
"",
_show_text_plain_block(),
"",
"(Deterministic mock answer for E2E.)",
]
)
def show_text_missing_answer() -> str:
"""The ``show me a block i cannot find`` answer: a locator the server has
no document for, so the client must land on its honest state."""
return "\n".join(
[
"Here is the block you asked for:",
"",
"```bor " + SHOW_TEXT_MISSING_TARGET + "#3-4",
f"A file that does not exist, re-typed by the model "
f"{SHOW_TEXT_MODEL_BODY_MARKER}.",
"```",
"",
"(Deterministic mock answer for E2E.)",
]
)
def _summary_seed_read_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-118 scripted summary-read request (see the
module docstring). The question carries the scripted call (``read
@@ -4165,6 +4527,22 @@ def compose_answer(body: dict[str, Any]) -> str:
# the user message, so a marker question always gets the inline
# answer whatever the gate says.
answer = INLINE_ANSWER
elif SHOW_TEXT_INLINE_TRIGGER in user.lower():
# Phase 182 (task 08, the verbatim-display suite): ONE locator fence
# and ZERO tool calls — the standalone-fence half of the contract
# (the server resolves the bytes from the row; the model's body is
# dropped at render). Checked BEFORE the DEFLECT_MODE branch, like
# TABLE_TRIGGER: the marker lives in the user message, so a marker
# question always gets the fenced answer whatever the gate says.
# NOT gated on the show_text clause (unlike the SHOW_TEXT_TRIGGER
# flow): on a BOR_ANSWER_TEXT=0 app the same text lands with no
# answer_text on the wire, which is the suite's witness that the
# client never paints the model's bytes.
answer = show_text_inline_answer(body)
elif SHOW_TEXT_MISSING_TRIGGER in user.lower():
# Phase 182 (task 08): the unresolvable locator — the client's honest
# unresolved state is the subject. Same branch convention as above.
answer = show_text_missing_answer()
elif HISTORY_TRIGGER in user.lower():
# Phase 74 (TODO L4, chat history): the deterministic history
# echo — proves on the wire that the client's prior turns (and
@@ -5310,6 +5688,28 @@ def chat_completions(body: dict[str, Any]) -> Any:
# 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).
# Phase 182 (task 08): the deterministic SHOW_TEXT marker flow —
# checked BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger
# phrase — the phase-71/72/94/118 ordering convention; the trigger
# needs the show_text clause, so deflected turns and a
# BOR_ANSWER_TEXT=0 app never hit it).
show_text = _show_text_flow(body)
if show_text is not None:
if show_text[0] == "call":
stream = _tool_call_stream(
"show_text",
_show_text_call_arguments(show_text[1], show_text[2], show_text[3]),
"call_show_text",
)
else: # "answer" — the fence-carrying final answer
stream = _sse_stream(
_apply_max_tokens(show_text[1], body.get("max_tokens")), 0.0
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
turn_progress = _turn_progress_flow(body)
if turn_progress is not None:
stream = _turn_progress_stream(turn_progress)
+15 -3
View File
@@ -553,7 +553,7 @@ def test_admin_sees_groups_and_source_badges(page: Page, app_url: str, db_ready:
return el.scrollHeight > el.clientHeight;
}"""
)
assert scrolls, "78 rows must overflow the bounded card (the scroll must bite)"
assert scrolls, "the catalogue rows must overflow the bounded card (the scroll must bite)"
# The groups render in the server's order (the pinned display
# groups — at least these two).
@@ -589,7 +589,19 @@ def test_admin_sees_groups_and_source_badges(page: Page, app_url: str, db_ready:
du = page.locator('tr[data-key="database_url"]')
assert du.locator(".settings-control").get_attribute("type") == "password"
assert du.locator(".settings-control").input_value() == ""
assert "reese" not in page.content(), "the DB URL's credentials leaked into the DOM"
# The whole page carries NO trace of the URL's credential pair, and
# the catalogue itself carries no `reese` at all (the user, the pass
# and the DB name are all `reese`/`brain_of_reese`). The strict
# no-`reese` sweep is scoped to the panel on purpose: the shell also
# ships the CHAT view, whose onboarding chips follow the newest saved
# chats in the one shared E2E database (phase 103) — a chat left by
# an unrelated suite (its question names `reeselink.json`) is not a
# credentials leak, and an unscoped sweep would make this assertion
# depend on which suite saved a chat last.
assert "reese:reese" not in page.content(), "the DB URL leaked into the DOM"
assert "reese" not in page.locator(".settings-shell").inner_html(), (
"the DB URL's credentials leaked into the settings catalogue"
)
expect(du.locator(".settings-reset")).to_be_disabled()
assert "read-only" in (du.locator(".settings-reset").get_attribute("title") or "")
@@ -984,7 +996,7 @@ def test_clear_all_overrides(page: Page, app_url: str, db_ready: None) -> None:
# The re-GET settles the badges (the status line lands BEFORE the
# re-render — the §7.4 lifecycle); wait for the settled state
# rather than snapshotting a mid-flight render (the combined-run
# race — the 78-item catalog's re-GET can outlast the status
# race — the full catalog's re-GET can outlast the status
# poll's gap under a three-suite run, phase-173 regression pass).
expect(page.locator(".settings-src.is-db")).to_have_count(0, timeout=30_000)
expect(page.locator('tr[data-key="app_name"] .settings-src')).to_have_text("env")
+15 -2
View File
@@ -723,7 +723,16 @@ def test_feature_flow_answer_figure_chips_and_zero_reads(
assert len(captured) == 2, (
f"expected exactly 2 model calls (call + answer), got {len(captured)}"
)
assert _offered_tool_names(captured[0]) == ["ls", "read", "grep", "show_image"]
# Phase 182 (task 03) pin update, deliberate: ``answer_text``
# defaults ON, so ``show_text`` rides LAST in the offer too — the
# ``answer_images`` gate itself is unchanged.
assert _offered_tool_names(captured[0]) == [
"ls",
"read",
"grep",
"show_image",
"show_text",
]
assert _executed_tool_calls(captured) == ["show_image"]
tool_results = [
m.get("content") or ""
@@ -941,10 +950,14 @@ def test_toggle_off_flow_no_tool_no_answer_image_key(
assert len(captured) == 1, (
f"expected exactly 1 model call (no tool round), got {len(captured)}"
)
assert _offered_tool_names(captured[0]) == ["ls", "read", "grep"], (
# Phase 182 (task 03) pin update, deliberate: ``show_text`` is in
# the default offer (``answer_text`` defaults ON); ``show_image``
# is still the tool that must NOT leak in here.
assert "show_image" not in _offered_tool_names(captured[0]), (
f"show_image leaked into the default-off offer: "
f"{_offered_tool_names(captured[0])}"
)
assert _offered_tool_names(captured[0]) == ["ls", "read", "grep", "show_text"]
assert _executed_tool_calls(captured) == []
# The saved brain record carries NO answer_image key (ABSENT — the
@@ -17,7 +17,7 @@ Test → contract mapping (one story, one phase, one isolated file):
small" pin: at 375px the admin arrives at the settings view (the
phase-168 gate flow), and a representative spread of rows — one
``type="text"`` input, one ``select``, one ``textarea`` (three
distinct control types among the catalogue's 78 rows; the
distinct control types among the catalogue's rows; the
phase-168 catalogue guarantees text / number / select / textarea /
password controls) — each renders its ``.settings-control``
bounding box ≥ 192px wide (12 rem — owner-locked A3: the
@@ -280,7 +280,7 @@ def test_mobile_value_inputs_are_usable_width(
# A representative spread: the FIRST row of each of three
# distinct control types (one type="text" input, one select, one
# textarea) among the catalogue's 78 rows (the phase-168
# textarea) among the catalogue's rows (the phase-168
# catalogue guarantees text / number / select / textarea /
# password controls).
keys = page.evaluate(
File diff suppressed because it is too large Load Diff
+128 -3
View File
@@ -9,7 +9,7 @@ The pins (00_phase, Testing & Quality):
* the auth matrix — anonymous AND a signed-in token USER get 403 on ALL
four routes (the router-wide ``require_admin`` closes the surface on
every method), the admin 200s on all four;
* the shape — ``items`` length == ``len(Settings.model_fields)`` (78),
* the shape — ``items`` length == ``len(Settings.model_fields)`` (one per field),
groups in ``GROUPS`` order (the stable order the E2E pins against),
``totals.count`` / ``overridden`` correct, ``overlay_enabled`` true;
on an empty table a field whose ``BOR_*`` var the test env sets
@@ -152,7 +152,7 @@ def test_auth_matrix_all_four_routes(client: TestClient, db: Session) -> None:
def test_catalogue_shape_and_sources_on_empty_table(admin_client) -> None:
"""The whole catalogue, grouped and stable: 78 items (exactly
"""The whole catalogue, grouped and stable: one item per field (exactly
``Settings.model_fields``), groups in ``GROUPS`` order (each group a
contiguous block, the block order = ``GROUPS``), ``totals`` correct,
``overlay_enabled`` true. On an empty table: the env-var-set field
@@ -418,7 +418,7 @@ def test_kill_switch_disables_the_mechanism_not_the_panel(
def test_catalogue_stays_under_the_byte_budget(admin_client) -> None:
"""The whole catalogue stays under the phase-167 INFO bar (64 KB —
``BOR_LOG_LARGE_RESPONSE_BYTES``), so ``GET /api/admin/settings``
never floods the log (78 items ≈ 20 KB; the audit script re-verifies
never floods the log (the whole catalogue ≈ 20 KB; the audit script re-verifies
the 256 KB budget in task 06)."""
r = admin_client.get(API)
assert r.status_code == 200
@@ -708,6 +708,131 @@ def test_llm_summary_thinking_level_row_contract(
admin_client.delete(f"{API}/llm_summary_thinking_level") # no row survives
def test_verbatim_display_rows_contract(
admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 182 (task 01, LOCKED A8): the three verbatim-display rows are
present in the ``Agent tools`` group with the right labels and types
(``bool`` + two ``int``s, defaults ``true`` / ``12000`` / ``5``) and
behave through the generic overlay machinery:
(a) the GET rows carry group/label/type/env_var, are neither secret nor
restart-required (the chat request path reads them per turn — live);
(b) a PUT stores the RAW string in ``app_settings`` and the value
resolves live WITHOUT a restart;
(c) ``0`` is storable on both budgets — it is INERT, not an error
(``app_settings`` holds ``"0"``, the effective value is ``0``);
(d) a negative value is refused by the validate-before-store gate (A5)
with a 422 naming the field and NOTHING stored — the same rule that
refuses startup; a ``false`` for the master switch is storable
(the kill switch);
(e) a DELETE resets each to the env default;
(f) the non-admin 403 gate applies.
"""
# A deterministic base (machine-independent, the kill-switch test's
# idiom): the code defaults, no row in the table.
for var in (
"BOR_ANSWER_TEXT",
"BOR_SHOW_TEXT_MAX_CHARS",
"BOR_SHOW_TEXT_MAX_BLOCKS",
):
monkeypatch.delenv(var, raising=False)
invalidate_settings()
# (a) the rows.
r = admin_client.get(API)
assert r.status_code == 200, r.text
expect = {
"answer_text": ("Answer text", "bool", "true", "BOR_ANSWER_TEXT"),
"show_text_max_chars": (
"Show text max chars",
"int",
"12000",
"BOR_SHOW_TEXT_MAX_CHARS",
),
"show_text_max_blocks": (
"Show text max blocks",
"int",
"5",
"BOR_SHOW_TEXT_MAX_BLOCKS",
),
}
for key, (label, typ, default, envname) in expect.items():
item = _item(r.json(), key)
assert item["group"] == "Agent tools"
assert item["label"] == label
assert item["type"] == typ
assert item["env_var"] == envname
assert item["secret"] is False
assert item["masked"] is False
assert item["restart_required"] is False
assert item["overridable"] is True
assert item["value"] == default
assert item["default"] == default
assert item["source"] == "default" # env var unset, no row
assert item["has_override"] is False
assert item["db_value"] is None
# (b) PUT stores the raw string and resolves live, no restart.
for key, sent, resolved in (
("answer_text", "false", False),
("show_text_max_chars", "2000", 2000),
("show_text_max_blocks", "2", 2),
):
r = admin_client.put(f"{API}/{key}", json={"value": sent})
assert r.status_code == 200, r.text
assert r.json()["source"] == "db"
assert r.json()["has_override"] is True
assert r.json()["db_value"] == sent
assert db.execute(
text("SELECT value FROM app_settings WHERE key = :k"), {"k": key}
).scalar() == sent # stored raw, exactly as sent
assert getattr(get_settings(), key) == resolved # live, no restart
# (c) ``0`` is storable on both budgets (inert, not an error).
for key in ("show_text_max_chars", "show_text_max_blocks"):
r = admin_client.put(f"{API}/{key}", json={"value": "0"})
assert r.status_code == 200, r.text
assert r.json()["value"] == "0"
assert db.execute(
text("SELECT value FROM app_settings WHERE key = :k"), {"k": key}
).scalar() == "0"
assert getattr(get_settings(), key) == 0
# (d) a negative value is refused before anything is stored (A5) —
# the 422 names the field, the previous row is untouched.
for key in ("show_text_max_chars", "show_text_max_blocks"):
r = admin_client.put(f"{API}/{key}", json={"value": "-1"})
assert r.status_code == 422, r.text
assert key in r.json()["detail"]
assert db.execute(
text("SELECT value FROM app_settings WHERE key = :k"), {"k": key}
).scalar() == "0" # unchanged
assert getattr(get_settings(), key) == 0 # the live view is undisturbed
# (e) DELETE resets each to the env default (the vars are unset here,
# so the code default wins again).
for key, default in (
("answer_text", True),
("show_text_max_chars", 12_000),
("show_text_max_blocks", 5),
):
r = admin_client.delete(f"{API}/{key}")
assert r.status_code == 200, r.text
assert r.json()["has_override"] is False
assert r.json()["source"] == "default"
assert getattr(get_settings(), key) == default
assert db.execute(
text("SELECT count(*) FROM app_settings WHERE key = :k"), {"k": key}
).scalar() == 0
assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0
# (f) the non-admin 403 gate (asserted once for this family — the full
# four-route matrix is ``test_auth_matrix_all_four_routes``).
anon = TestClient(fastapi_app)
r = anon.put(f"{API}/answer_text", json={"value": "false"})
assert r.status_code == 403 and r.json() == {"detail": "admin only"}
def test_docs_token_is_write_only_masked_end_to_end(
admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
+70 -1
View File
@@ -13,7 +13,10 @@ the ``scripts/``-consumer surface (``import_extension_set``), and the
phase-172 thinking level (``llm_chat_thinking_level`` — the value the
LLM layer reads per request via ``get_settings()``; the off switch is
live-settable, A2) and its phase-178 summary twin (task 02:
``llm_summary_thinking_level``, default ``""`` = off, A2).
``llm_summary_thinking_level``, default ``""`` = off, A2), and the
phase-182 verbatim-display family (task 01: ``answer_text`` — the master
switch — plus the two ``show_text_max_*`` budgets, whose inert ``0``
values and reset-to-default resolve live with no restart).
The rows are inserted directly through the ``db`` session (the admin
API is task 04); the save itself is ``invalidate_settings()`` —
@@ -249,6 +252,72 @@ def test_llm_summary_thinking_level_live_apply_no_restart(
assert get_settings().llm_summary_thinking_level == ""
# ---------------------------------------------------------------------------
# the verbatim-display switch + budgets: save/reset resolve the live values
# (phase 182, task 01)
# ---------------------------------------------------------------------------
def test_verbatim_display_settings_live_apply_no_restart(
db: Session, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 182 (task 01, LOCKED A8): the verbatim-display master switch
and its two budgets follow the overlay live — a save (the PUT's write,
minus the API: ``_save`` + ``invalidate_settings()`` is exactly what
the PUT/DELETE call after the write) resolves
``get_settings().answer_text`` / ``.show_text_max_chars`` /
``.show_text_max_blocks`` WITHOUT a restart (the chat request path
reads them per turn via ``get_settings()``), the ``0`` values resolve
live as INERT (not an error, not coerced to the default), the bool
accepts the env-native ``false``/``0`` spellings, and a reset (the
DELETE) returns the env default.
"""
# A deterministic base (machine-independent, the kill-switch test's
# idiom): the code defaults, no row in the table.
for var in (
"BOR_ANSWER_TEXT",
"BOR_SHOW_TEXT_MAX_CHARS",
"BOR_SHOW_TEXT_MAX_BLOCKS",
):
monkeypatch.delenv(var, raising=False)
invalidate_settings()
assert get_settings().answer_text is True
assert get_settings().show_text_max_chars == 12_000
assert get_settings().show_text_max_blocks == 5
# Save: every value resolves through the overlay, no restart.
_save(db, "answer_text", "false")
_save(db, "show_text_max_chars", "2500")
_save(db, "show_text_max_blocks", "1")
invalidate_settings()
assert get_settings().answer_text is False
assert get_settings().show_text_max_chars == 2500
assert get_settings().show_text_max_blocks == 1
# ``0`` is the inert state (the field is not treated as "unset"), and
# the bool's other env-native spelling resolves to True.
_save(db, "answer_text", "1")
_save(db, "show_text_max_chars", "0")
_save(db, "show_text_max_blocks", "0")
invalidate_settings()
assert get_settings().answer_text is True
assert get_settings().show_text_max_chars == 0
assert get_settings().show_text_max_blocks == 0
# The reset returns the env defaults (no process restart in between).
db.execute(
text(
"DELETE FROM app_settings WHERE key IN "
"('answer_text', 'show_text_max_chars', 'show_text_max_blocks')"
)
)
db.commit()
invalidate_settings()
assert get_settings().answer_text is True
assert get_settings().show_text_max_chars == 12_000
assert get_settings().show_text_max_blocks == 5
# ---------------------------------------------------------------------------
# the chat concurrency semaphore follows the saved cap
# ---------------------------------------------------------------------------
+7 -7
View File
@@ -47,7 +47,7 @@ from app.config import Settings
from app.db import SessionLocal
from app.models import Document, FolderSummary, GitSource
from app.rag import agent
from app.rag.agent import AGENT_TOOLS, AgentHolder, run_agent
from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL, AgentHolder, run_agent
from app.rag.llm import (
LLMClient,
RetryPiece,
@@ -319,7 +319,7 @@ def test_ls_top_level_lists_registered_sources_through_run_agent(
holder, llm = _run_call(db, "ls", {})
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[0][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
assert llm.requests[1][0][3]["content"].startswith(
"2 sources:\n"
"\n"
@@ -482,7 +482,7 @@ def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(kb, src, db) -
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app")
)
assert holder.tool_calls == 0 and holder.read_docs == []
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL] # rejected → tools stay offered
def test_ls_unknown_folder_gets_not_a_folder_with_parents_subfolders(
@@ -505,7 +505,7 @@ def test_ls_unknown_folder_gets_not_a_folder_with_parents_subfolders(
"backups/ containers/ networking/"
)
assert holder.tool_calls == 0 and holder.read_docs == []
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL] # rejected → tools stay offered
# ---------- read (the canonical combined source/path form) ----------
@@ -595,7 +595,7 @@ def test_read_bare_path_single_source_suggestion_then_corrected_read(kb, db) ->
"No document at 'deep/nested/doc.md' — "
"did you mean 'Alpha/deep/nested/doc.md'?"
)
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[1][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
# Round 2: the corrected combined identity succeeds — the full
# content (plus the phase-106 D5 date line), the holder records the
# row, and it counts.
@@ -604,7 +604,7 @@ def test_read_bare_path_single_source_suggestion_then_corrected_read(kb, db) ->
"date: 2024-06-15\n"
"FULL-TEXT"
)
assert llm.requests[2][1] == AGENT_TOOLS
assert llm.requests[2][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
# SEC-14-04: short-lived session loads fresh copies
assert [d.id for d in holder.read_docs] == [created.id]
assert holder.tool_calls == 1 # only the corrected read executed
@@ -670,7 +670,7 @@ def test_grep_whole_kb_through_run_agent(kb, db) -> None:
holder, llm = _run_call(db, "grep", {"pattern": "needle"})
# Offered: the first request carries AGENT_TOOLS (the 3-tool list).
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[0][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
# Executed against the real DB: catalog order, grep-style lines.
assert llm.requests[1][0][3]["content"].startswith(
"Alpha/a/one.md:2: needle in one\n"
+416 -10
View File
@@ -36,10 +36,10 @@ from app.config import Settings, get_settings
from app.main import app as fastapi_app
from app.models import Chunk, Document, GitSource, QueryLog
from app.rag import agent
from app.rag.agent import AGENT_TOOLS, READ_TRUNCATION_NOTICE
from app.rag.agent import AGENT_TOOLS, READ_TRUNCATION_NOTICE, SHOW_TEXT_TOOL
from app.rag.importer import import_sources
from app.rag.llm import EmbeddingError, LLMError, StreamPiece, ToolCallPiece, ToolResultPiece
from app.rag.prompts import build_high_prompt
from app.rag.prompts import SHOW_TEXT_CLAUSE, build_high_prompt
from app.rag.retriever import TRUNCATION_MARKER
from app.schemas import ChatDoneEvent, SourceRef
from tests.conftest import ADMIN_PASSWORD
@@ -1291,9 +1291,12 @@ def test_grounded_turn_streams_tool_frames_and_cites_read_doc(
# (not spent budgets) bounds the loop, and the model answered while
# still being offered the tools (2 rounds < default cap 10).
assert len(scripted.seen_messages) == 3
assert scripted.seen_tools[0] == AGENT_TOOLS
assert scripted.seen_tools[1] == AGENT_TOOLS
assert scripted.seen_tools[2] == AGENT_TOOLS
# Phase 182 (task 03) pin update, deliberate: ``answer_text``
# defaults ON, so the grounded offer is the base three
# (element-for-element ``AGENT_TOOLS``) plus ``show_text``.
assert scripted.seen_tools[0] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
assert scripted.seen_tools[1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
assert scripted.seen_tools[2] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
# The query_log row carries the same combined source list.
(row,) = db.scalars(select(QueryLog)).all()
@@ -2887,7 +2890,10 @@ def test_show_image_turn_done_frame_carries_answer_image(
# The cited tier includes the same doc (the scripted read).
assert ("docs", "pic.png") in [(s["source"], s["path"]) for s in done["sources"]]
# The toggle was honoured on the wire: the first (agent) request
# offered the 4th tool.
# offered the 4th tool. Phase 182 (task 03) pin update, deliberate:
# with ``answer_text`` ON (the shipped default) ``show_text`` rides
# LAST too — the ``answer_images`` gate itself is unchanged (it
# still adds exactly ``show_image``, 3rd-position-offer order kept).
offered = scripted.seen_tools[0]
assert offered is not None
assert [t["function"]["name"] for t in offered] == [
@@ -2895,6 +2901,7 @@ def test_show_image_turn_done_frame_carries_answer_image(
"read",
"grep",
"show_image",
"show_text",
]
@@ -2903,8 +2910,9 @@ def test_toggle_off_same_flow_offers_three_tools_and_no_answer_image_key(
) -> None:
"""The SAME scripted flow with ``answer_images=False`` (the
default) → ``show_image`` is NOT in the offered tools (the mock's
recorded request carries exactly the three base tools) and the
``done`` frame has NO ``answer_image`` key — the feature's
recorded request carries the three base tools — plus ``show_text``,
phase 182's default-ON display tool, pin updated here deliberately)
and the ``done`` frame has NO ``answer_image`` key — the feature's
server-side gate is the OFFER (a model that was never offered the
tool never calls it, so no such call occurs in the flow)."""
_seed_image_doc(db) # resolvable, but the tool is never offered
@@ -2928,10 +2936,16 @@ def test_toggle_off_same_flow_offers_three_tools_and_no_answer_image_key(
done = frames[-1]
assert done["deflected"] is False
assert "answer_image" not in done # ABSENT, not null
# The offer: exactly the three base tools — no ``show_image``.
# The offer: the three base tools — no ``show_image`` (phase 182:
# ``show_text`` rides last, the default being ON).
offered = scripted.seen_tools[0]
assert offered is not None
assert [t["function"]["name"] for t in offered] == ["ls", "read", "grep"]
assert [t["function"]["name"] for t in offered] == [
"ls",
"read",
"grep",
"show_text",
]
def test_zero_tool_grounded_turn_done_frame_has_no_answer_image_key(
@@ -3202,3 +3216,395 @@ def test_grounded_turn_suggested_plain_image_doc_block_is_byte_identical(
"</document>"
)
assert "Referenced in" not in system
# ---------- phase 182 (task 04): the show_text clause reaches the model ----------
def _gate_settings(**overrides: Any) -> Settings:
"""``Settings`` for the endpoint with the live honesty gate plus
*overrides* (the thinking-kill-switch pattern above) — so only the
flag under test changes from the calibrated default."""
live = get_settings()
return Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
relevance_threshold=live.relevance_threshold,
**overrides,
)
def test_grounded_turn_carries_show_text_clause_and_tool_per_the_flag(
client, db, seeded_kb: FakeRagLLM, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Phase 182 (task 04, LOCKED A1/A8): on a GROUNDED turn the model
request carries the ``SHOW_TEXT_CLAUSE`` inside ``<tools>`` exactly
when ``BOR_ANSWER_TEXT`` is on — and the ``show_text`` tool itself is
offered on the same condition, so the model is never taught a tool it
was not given nor handed one it was not taught (``answer_images``
stays off here, so the offer is the three base tools alone off, the
three base tools + ``show_text`` on)."""
for flag in (True, False):
monkeypatch.setattr(
chat_api, "get_settings", lambda f=flag: _gate_settings(answer_text=f)
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
first_request = len(seeded_kb.seen_tools)
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["deflected"] is False, f"flag={flag}"
system = seeded_kb.seen_messages[first_request][0]["content"]
assert (SHOW_TEXT_CLAUSE in system) is flag, f"flag={flag}"
if flag:
assert system.index("<tools>") < system.index(SHOW_TEXT_CLAUSE)
assert system.index(SHOW_TEXT_CLAUSE) < system.index("</tools>")
offered = [t["function"]["name"] for t in (seeded_kb.seen_tools[first_request] or [])]
assert ("show_text" in offered) is flag, f"flag={flag}"
assert len(offered) == (4 if flag else 3), f"flag={flag}: {offered}"
# ---------------------------------------------------------------------------
# Phase 182 (task 05): the done frame's ADDITIVE ``answer_text`` array —
# the SERVER-resolved verbatim blocks (the ``show_text`` tool's and the
# ones the answer's own ```bor locator fences name), the byte-identity of
# every frame WITHOUT them, and the per-turn line's ``verbatim_blocks=N``
# tail. The resolver itself is pinned in tests/unit/test_verbatim_resolver.py;
# here the wire is the subject.
# ---------------------------------------------------------------------------
#: A fixture doc with a stable, countable body (16 lines as imported).
_K8S = "docs/homelab/kubernetes.md"
def _verbatim_settings(**overrides: Any) -> Settings:
"""The endpoint's settings view with the live gate plus *overrides*
(the ``_gate_settings`` pattern above) — only the flag under test
changes from the calibrated default."""
return _gate_settings(**overrides)
def _doc_lines(db, combined: str) -> list[str]:
"""The indexed row's content split the way the resolver splits it
(``splitlines()``, 1-based) — the authority the frame's text is
compared against, never a copy typed into this file."""
source, _, path = combined.partition("/")
content = db.scalar(
select(Document.content).where(
Document.source == source, Document.path == path
)
)
assert content is not None, combined
return content.splitlines()
def test_show_text_turn_done_frame_carries_the_server_block(
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
"""A scripted grounded turn whose model calls
``show_text(docs/homelab/kubernetes.md, offset=3, limit=100)`` → the
``done`` frame carries ONE ``answer_text`` entry whose ``text`` is
byte-identical to ``documents.content`` lines 3–16 (the overshooting
``limit`` CLAMPED, LOCKED A2), whose extent + ``total_lines`` are the
SERVER's, and whose ``locator`` is the canonical
``source/path#3-16`` (D7)."""
scripted = FakeRagLLM(
tool_script=[
[
ToolCallPiece(
id="call_1",
name="show_text",
arguments={"path": _K8S, "offset": 3, "limit": 100},
)
]
]
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
with caplog.at_level(logging.DEBUG, logger="app.response_budget"):
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
lines = _doc_lines(db, _K8S)
# Phase 167's byte-budget line covers the done-bearing turn (the
# stream total, ``stream=1``) — the audit surface that shows the
# blocks' growth, pinned WELL inside the 256 KB budget here.
budget = [
r.getMessage()
for r in caplog.records
if " path=/api/chat " in r.getMessage()
]
assert len(budget) == 1, budget
size = int(re.search(r"bytes=(\d+)", budget[0]).group(1)) # pyright: ignore[reportOptionalMemberAccess]
assert 0 < size < 256 * 1024, budget[0]
assert done["deflected"] is False
assert done["answer_text"] == [
{
"source": "docs",
"path": "homelab/kubernetes.md",
"title": "Kubernetes Homelab Cluster",
"start_line": 3,
"end_line": len(lines),
"total_lines": len(lines),
"text": "\n".join(lines[2:]),
"truncated": False,
"locator": f"{_K8S}#3-{len(lines)}",
}
]
def test_answer_fence_resolves_the_same_block_without_a_tool_call(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""D1/D3: the ANSWER alone carries a ```bor locator fence (no tool
call at all) and the block still resolves — resolution is a plain
``(source, path)`` lookup, not a replay of a tool call. The frame's
text is the DB's bytes: the model's (deliberately corrupt) fence body
appears NOWHERE on the wire."""
scripted = FakeRagLLM(
answer=(
"Here it is:\n"
"```bor docs/homelab/kubernetes.md 3-5\n"
"Exec=/MODEL_TYPED_LIES/never-render.bin\n"
"```\n"
"That is the whole range."
)
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
lines = _doc_lines(db, _K8S)
done = frames[-1]
assert done["answer_text"] == [
{
"source": "docs",
"path": "homelab/kubernetes.md",
"title": "Kubernetes Homelab Cluster",
"start_line": 3,
"end_line": 5,
"total_lines": len(lines),
"text": "\n".join(lines[2:5]),
"truncated": False,
"locator": f"{_K8S}#3-5",
}
]
# The model's fence body never reaches the wire as a RESOLVED block
# (the delta frames still carry the answer text — task 06/07 drop the
# body at render time; here the pin is that the frame's bytes are the
# DB's, not the model's).
assert done["answer_text"][0]["text"] == "\n".join(lines[2:5])
assert "MODEL_TYPED_LIES" not in json.dumps(done["answer_text"])
# The prose frames still stream the model's own answer text (the
# render-time body drop is task 06/07's job, pinned there) — the
# frame is what carries the truth.
answer = "".join(
f["text"] for f in frames if f.get("type") == "delta"
)
assert "MODEL_TYPED_LIES" in answer
def test_fence_plus_identical_tool_call_produce_exactly_one_block(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""D2 dedupe: the tool shows the range AND the answer locates the
same canonical locator → exactly ONE entry (the fence wins, the block
is not painted twice)."""
scripted = FakeRagLLM(
answer=(
"The block:\n"
"```bor docs/homelab/kubernetes.md#3-5\n"
"```"
),
tool_script=[
[
ToolCallPiece(
id="call_1",
name="show_text",
arguments={"path": _K8S, "offset": 3, "limit": 3},
)
]
],
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert len(done["answer_text"]) == 1
assert done["answer_text"][0]["locator"] == f"{_K8S}#3-5"
def test_three_blocks_with_max_blocks_two_send_two_in_answer_order(
client,
db,
seeded_kb: FakeRagLLM,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""D8: three fences in the answer, ``BOR_SHOW_TEXT_MAX_BLOCKS=2`` →
exactly TWO entries, in ANSWER order (the cut is the resolver's, the
frame keeps its order, and the log line counts what was SENT)."""
monkeypatch.setattr(
chat_api, "get_settings", lambda: _verbatim_settings(show_text_max_blocks=2)
)
scripted = FakeRagLLM(
answer=(
"A:\n```bor docs/homelab/kubernetes.md 1-2\n```\n"
"B:\n```bor docs/homelab/backups.md 1-2\n```\n"
"C:\n```bor docs/deployments/new-service.md 1-2\n```"
)
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
with caplog.at_level("INFO", logger="app.chat"):
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert [b["locator"] for b in done["answer_text"]] == [
"docs/homelab/kubernetes.md#1-2",
"docs/homelab/backups.md#1-2",
]
lines = [
r.getMessage() for r in caplog.records if "question=" in r.getMessage()
]
assert lines and lines[-1].endswith("verbatim_blocks=2")
def test_turn_without_blocks_serializes_without_the_answer_text_key(
client, db, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
) -> None:
"""A plain grounded turn (no tool call, no fence): the ``done`` frame
has NO ``answer_text`` key — checked on the RAW wire text — and its
key set is exactly the pre-phase one (the A9 byte-identity pin), with
``verbatim_blocks=0`` on the per-turn line."""
scripted = FakeRagLLM()
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
with caplog.at_level("INFO", logger="app.chat"):
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert "answer_text" not in done
assert set(done) == {
"type",
"deflected",
"sources",
"related",
"suggestions",
}
lines = [
r.getMessage() for r in caplog.records if "question=" in r.getMessage()
]
assert lines and lines[-1].endswith("verbatim_blocks=0")
def test_answer_text_off_keeps_the_pre_phase_frame_and_no_tool(
client, db, seeded_kb: FakeRagLLM, monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The master switch OFF (LOCKED A8/A9) with an answer that DOES
carry a fence: the frame is byte-for-byte the pre-phase shape (no
key), the line reads ``verbatim_blocks=0``, and the model was never
offered ``show_text`` — every byte-identity contract is exactly
pre-phase."""
monkeypatch.setattr(
chat_api, "get_settings", lambda: _verbatim_settings(answer_text=False)
)
scripted = FakeRagLLM(
answer=(
"Here it is:\n"
"```bor docs/homelab/kubernetes.md 3-5\n"
"Exec=/MODEL_TYPED_LIES/never-render.bin\n"
"```"
)
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
with caplog.at_level("INFO", logger="app.chat"):
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert done["deflected"] is False
assert set(done) == {"type", "deflected", "sources", "related", "suggestions"}
offered = [t["function"]["name"] for t in (scripted.seen_tools[0] or [])]
assert "show_text" not in offered
lines = [
r.getMessage() for r in caplog.records if "question=" in r.getMessage()
]
assert lines and lines[-1].endswith("verbatim_blocks=0")
def test_deflected_frame_has_no_answer_text_even_with_a_fence(
client, db, seeded_kb: FakeRagLLM
) -> None:
"""D9: a deflected answer whose mocked text contains a fence still
resolves NOTHING — the frame has no ``answer_text`` key (the
disclaimer never leaks KB bytes) and no tools were offered."""
scripted = FakeRagLLM(
answer=(
"I haven't done anything like that.\n"
"```bor docs/homelab/kubernetes.md 3-5\n"
"```\n"
"Maybe try a saved runbook?"
)
)
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
_, _, frames = _stream_chat(client, OFF_TOPIC)
finally:
fastapi_app.dependency_overrides.clear()
done = frames[-1]
assert done["deflected"] is True
assert "answer_text" not in done
def test_resolution_failure_degrades_to_no_blocks(
client,
db,
seeded_kb: FakeRagLLM,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The answer already went out — a DB failure inside the done-time
resolution must not break the stream: the turn settles with the plain
pre-phase ``done`` frame (no key), the line reads
``verbatim_blocks=0``, and the failure is named on the log (the
query_log-write degradation precedent, never a silent swallow)."""
def _boom(*_a: Any, **_kw: Any) -> list[Any]:
raise RuntimeError("the KB went away mid-answer")
monkeypatch.setattr(chat_api, "resolve_answer_blocks", _boom)
scripted = FakeRagLLM(answer="Here:\n```bor docs/homelab/kubernetes.md 1-2\n```")
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: scripted
try:
with caplog.at_level("INFO", logger="app.chat"):
_, _, frames = _stream_chat(client, QUESTION)
finally:
fastapi_app.dependency_overrides.clear()
assert frames[-1]["type"] == "done"
assert "answer_text" not in frames[-1]
lines = [
r.getMessage()
for r in caplog.records
if "question=" in r.getMessage() or "verbatim" in r.getMessage()
]
assert any("verbatim block resolution failed" in m for m in lines)
turn = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
assert turn and turn[-1].endswith("verbatim_blocks=0")
+114
View File
@@ -2302,3 +2302,117 @@ def test_record_without_segments_round_trips_key_absent(
assert r2.status_code == 201, r2.text
got2 = admin_client.get(f"/api/chats/{r2.json()['id']}")
assert got2.json()["messages"][0]["segments"] == []
# ---------------------------------------------------------------------------
# Phase 182 (task 05): the answer's VERBATIM display blocks on the BRAIN
# record — the persisted twin of the done frame's ``answer_text`` (the
# show_text tool's blocks and the ones a ```bor locator fence resolved).
# Same phase-113 lesson as ``related`` / ``answer_image``: the key must be
# ACCEPTED (``extra="forbid"`` otherwise 422s the done-time auto-save and
# the quiet failure leaves the turn unsaved), round-trip byte-for-byte so
# the restore path and the anonymous shared page re-render the panels from
# the record's own bytes (LOCKED A4 — no new route), and stay ABSENT for
# every record without blocks (A9, the phase-50 round-trip contract).
# ---------------------------------------------------------------------------
#: One resolved block — the done frame's ``answer_text`` item shape
#: (the SERVER's text + its 1-based inclusive extent + the canonical
#: locator the client matches its placeholder against).
VERBATIM_BLOCK: dict[str, Any] = {
"source": "Homelab",
"path": "quadlet/gufo.container",
"title": "gufo.container",
"start_line": 3,
"end_line": 9,
"total_lines": 21,
"text": "[Unit]\nDescription=gufo\n[Service]\nExecStart=/usr/bin/gufo",
"truncated": False,
"locator": "Homelab/quadlet/gufo.container#3-9",
}
def test_create_round_trips_the_brain_answer_text_blocks(
admin_client: TestClient, db: Session
) -> None:
"""A brain record carrying ``answer_text`` → 201 (NOT the silent 422)
and the blocks survive byte-for-byte in the stored JSONB, the create
response and the admin detail — the panels restore from the record."""
brain = {**FULL_BRAIN, "answer_text": [VERBATIM_BLOCK]}
r = admin_client.post(
"/api/chats",
json={"messages": [_user(FIRST_QUESTION), brain]},
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
body = r.json()
assert body["messages"][1]["answer_text"] == [VERBATIM_BLOCK]
assert "answer_text" not in body["messages"][0]
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": body["id"]},
).scalar_one()
assert raw[1]["answer_text"] == [VERBATIM_BLOCK]
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
assert got.json()["messages"][1]["answer_text"] == [VERBATIM_BLOCK]
def test_create_without_answer_text_round_trips_unchanged(
admin_client: TestClient,
) -> None:
"""A record WITHOUT the key (every pre-phase-182 chat, every turn
with no blocks) round-trips UNCHANGED — no ``answer_text`` key
anywhere, ABSENT rather than ``null``/``[]`` on every surface (A9)."""
brain = {**FULL_BRAIN}
r = admin_client.post(
"/api/chats",
json={"messages": [_user(FIRST_QUESTION), brain]},
)
assert r.status_code == 201, r.text
body = r.json()
assert body["messages"] == _expect([_user(FIRST_QUESTION), brain])
assert "answer_text" not in body["messages"][0]
assert "answer_text" not in body["messages"][1]
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
assert "answer_text" not in got.json()["messages"][1]
def test_empty_answer_text_list_is_dropped_not_stored(
admin_client: TestClient,
) -> None:
"""An empty ``answer_text`` (the client saving a turn whose resolver
returned nothing) serializes AWAY — never ``null``, never ``[]`` —
so the stored record is byte-identical to the no-key shape (A9)."""
brain = {**FULL_BRAIN, "answer_text": []}
r = admin_client.post(
"/api/chats",
json={"messages": [_user(FIRST_QUESTION), brain]},
)
assert r.status_code == 201, r.text
assert "answer_text" not in r.json()["messages"][1]
def test_shared_serve_includes_the_brain_answer_text_blocks(
admin_client: TestClient,
) -> None:
"""The anonymous shared page renders the SAME panels from the share's
own record (A4 — no new bytes route): the public snapshot's brain
record carries the blocks verbatim, the user record does not."""
brain = {**FULL_BRAIN, "answer_text": [VERBATIM_BLOCK]}
r = admin_client.post(
"/api/chats",
json={"messages": [_user(FIRST_QUESTION), brain]},
)
assert r.status_code == 201, r.text
share_url = _share(admin_client, r.json()["id"])["share_url"]
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
got = anon.get(f"/api{share_url}")
assert got.status_code == 200
messages = got.json()["messages"]
assert messages[1]["answer_text"] == [VERBATIM_BLOCK]
assert "answer_text" not in messages[0]
@@ -145,6 +145,10 @@ def test_no_row_high_prompt_byte_identical_to_pre_phase(
notes=[],
kb_overview=None,
folder_lines=_suggested_folder_lines(db),
# Phase 182 (task 04): the grounded build carries the show_text
# clause exactly when BOR_ANSWER_TEXT is on (the default) — read
# the live flag so this pin holds with the switch either way.
show_text=get_settings().answer_text,
)
assert system["content"] == expected
assert "<knowledge_base>" not in system["content"]
@@ -208,6 +212,8 @@ def test_row_high_prompt_carries_kb_section_exactly(
notes=[],
kb_overview=OVERVIEW,
folder_lines=_suggested_folder_lines(db),
# Phase 182 (task 04): same as above — the flag is the live one.
show_text=get_settings().answer_text,
)
assert system["content"] == expected
@@ -69,6 +69,7 @@ from app.rag.agent import (
AGENT_TOOLS,
READ_TRUNCATION_NOTICE,
REREAD_PARTIAL,
SHOW_TEXT_TOOL,
AgentHolder,
run_agent,
)
@@ -361,8 +362,11 @@ def test_grep_then_range_read_answers_the_find_traps_question(
assert len(llm.requests) == 3
assert [call.name for call in llm.calls] == ["grep", "read"]
# The tools stay offered on every request (phase 45 — no budgets).
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[2][1] == AGENT_TOOLS
# Phase 182 (task 03) pin update, deliberate: ``answer_text``
# defaults ON, so the grounded offer is the base three
# (element-for-element ``AGENT_TOOLS``) plus ``show_text``.
assert llm.requests[0][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
assert llm.requests[2][1] == [*AGENT_TOOLS, SHOW_TEXT_TOOL]
# Round 1: the lowercase pattern (grep is case-insensitive).
assert llm.calls[0].arguments == {"pattern": PATTERN}
+568 -34
View File
@@ -73,9 +73,17 @@ from app.models import Document, GitSource
from app.rag import agent
from app.rag.agent import (
AGENT_TOOLS,
MISSING_SHOW_TEXT_ARGS,
READ_TRUNCATION_NOTICE,
SHOW_TEXT_ALREADY_SHOWN,
SHOW_TEXT_BLOCK_CAP,
SHOW_TEXT_NOT_A_TEXT_DOC,
SHOW_TEXT_TOOL,
SHOWN_TEXT_TRUNCATION_NOTICE,
UNKNOWN_TOOL,
AgentHolder,
MalformedReplyError,
agent_tools,
run_agent,
)
from app.rag.llm import (
@@ -98,6 +106,20 @@ def _settings(**kwargs: Any) -> Settings:
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
#: Phase 182 (task 03) pin update, deliberate: ``answer_text``
#: (``BOR_ANSWER_TEXT``) defaults ON, so the default grounded offer is
#: the three base tools (element-for-element ``AGENT_TOOLS``, the
#: byte-identity pinned separately by ``test_agent_tools_names_and_
#: parameters``) plus the ``show_text`` display tool — every
#: "tools stay offered" pin in this module now expects this list. The
#: pre-phase offer is still exactly ``AGENT_TOOLS`` with
#: ``answer_text=False`` and ``answer_images=False`` (pinned in the
#: phase-182 offer-matrix tests below). With ``answer_images=True`` the
#: offer is ``[*AGENT_TOOLS, SHOW_IMAGE_TOOL, SHOW_TEXT_TOOL]`` (pinned
#: in ``tests/unit/test_image_chips_and_answers.py``).
_OFFER = [*AGENT_TOOLS, SHOW_TEXT_TOOL]
#: The fixture documents' fixed creation date (phase 106, D5): the
#: agent formats ``doc.created_at`` on the ``read`` result's second
#: line — the detached fixture rows carry it exactly as the NOT NULL
@@ -547,9 +569,9 @@ def test_ls_then_read_then_answer(monkeypatch: pytest.MonkeyPatch) -> None:
# Phase 45: no per-tool budgets — the tools stay offered on every
# request (the round cap, not spent budgets, bounds the loop), so
# the answer request still carries them (2 rounds < default cap 10).
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[2][1] == AGENT_TOOLS
assert llm.requests[0][1] == _OFFER
assert llm.requests[1][1] == _OFFER
assert llm.requests[2][1] == _OFFER
assert len(llm.requests) == 3
# read splits the combined form at the FIRST slash — one exact
@@ -855,7 +877,7 @@ def test_ls_scoped_known_source_with_zero_docs_counts(
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"].startswith("Homelab — 0 documents, 0 folders:")
assert holder.tool_calls == 1 # an executed ls, not a refusal
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[1][1] == _OFFER
def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -873,7 +895,7 @@ def test_ls_scoped_unknown_source_refused(monkeypatch: pytest.MonkeyPatch) -> No
assert llm.requests[1][0][3]["content"].startswith(
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="Ghost")
)
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(
@@ -900,7 +922,7 @@ def test_ls_path_like_scope_unknown_source_gets_no_source_refusal(
assert llm.requests[1][0][3]["content"].startswith(
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope="app")
)
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
@@ -921,7 +943,7 @@ def test_ls_dot_scope_gets_not_a_directory_teaching_refusal(
assert llm.requests[1][0][3]["content"].startswith(
agent.NO_SOURCE_NOT_A_DIRECTORY.format(scope=".")
)
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
@@ -958,7 +980,7 @@ def test_ls_unknown_top_level_folder_gets_not_a_folder_teaching(
"'Homelab/netwoking' is not a folder — Homelab has: "
"backups/ containers/ networking/"
)
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
def test_ls_unknown_nested_folder_gets_not_a_folder_with_nested_parent(
@@ -1527,7 +1549,7 @@ def test_read_bare_source_name_refused_without_db(monkeypatch: pytest.MonkeyPatc
assert llm.requests[1][0][3]["content"].startswith(
"No document at 'Homelab' — check the ls output."
)
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[1][1] == _OFFER
def test_read_unknown_path_refused_echoing_argument(
@@ -1549,7 +1571,7 @@ def test_read_unknown_path_refused_echoing_argument(
assert llm.requests[1][0][3]["content"].startswith(
"No document at 'S/ghost.md' — check the ls output."
)
assert llm.requests[1][1] == AGENT_TOOLS # tools stay offered (cap bounds)
assert llm.requests[1][1] == _OFFER # tools stay offered (cap bounds)
# ---------- read/grep: the "did you mean …?" suggestions (phase 72, task 02) ----------
@@ -1649,7 +1671,7 @@ def test_read_bare_path_exact_match_gets_did_you_mean(
"No document at 'active/container_caddy/caddy.md' — "
"did you mean 'Homelab/active/container_caddy/caddy.md'?"
)
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
def test_read_bare_path_suffix_match_gets_did_you_mean(
@@ -1767,7 +1789,7 @@ def test_read_bare_filename_without_slash_keeps_no_db_refusal(
assert llm.requests[1][0][3]["content"].startswith(
"No document at 'caddy.md' — check the ls output."
)
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[1][1] == _OFFER
def test_read_bare_path_of_suggested_doc_gets_suggestion_then_succeeds(
@@ -1850,7 +1872,7 @@ def test_read_missing_arguments_refused(
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [] and holder.tool_calls == 0
assert llm.requests[1][0][3]["content"].startswith(agent.MISSING_READ_ARGS)
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[1][1] == _OFFER
def test_reading_a_suggested_seed_doc_adds_its_full_text(
@@ -1887,7 +1909,7 @@ def test_reading_a_suggested_seed_doc_adds_its_full_text(
assert llm.requests[1][0][3]["content"].startswith(
"Document Homelab/kubernetes.md:\ndate: 2024-06-15\nK8S-CONTENT"
)
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[1][1] == _OFFER
def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -1938,7 +1960,7 @@ def test_re_reading_a_suggested_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT)
# Rejected → the tools are still offered on the next request (the
# round cap is the only bound).
assert llm.requests[2][1] == AGENT_TOOLS
assert llm.requests[2][1] == _OFFER
def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -1960,7 +1982,7 @@ def test_reading_an_already_read_doc_is_deduped(monkeypatch: pytest.MonkeyPatch)
)
assert llm.requests[2][0][5]["content"].startswith(agent.ALREADY_IN_CONTEXT)
# Rejected → the tools are still offered on the next request…
assert llm.requests[2][1] == AGENT_TOOLS
assert llm.requests[2][1] == _OFFER
# ---------- phase 95: the read cap (bounded reads, honest truncation) ----------
@@ -2359,7 +2381,7 @@ def test_bad_range_args_refused_before_any_lookup(
assert llm.requests[1][0][3]["content"].startswith(agent.BAD_RANGE_ARGS.format(
offset=2.5, limit=None
))
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[1][1] == _OFFER
def test_bad_range_args_beats_the_dedupe(
@@ -3678,7 +3700,7 @@ def test_unknown_tool_name_refused(monkeypatch: pytest.MonkeyPatch) -> None:
asyncio.run(_run(llm, holder, _settings()))
assert holder.read_docs == [] and holder.tool_calls == 0
assert llm.requests[1][0][3]["content"].startswith(agent.UNKNOWN_TOOL)
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
# ---------- phase 145 (task 01): the origin/branch lines in the read result ----------
@@ -4062,7 +4084,7 @@ def test_grep_whole_kb_grep_style_output(monkeypatch: pytest.MonkeyPatch) -> Non
)
assert holder.tool_calls == 1
assert holder.read_docs == [] # locked A5: a grep adds no context
assert llm.requests[1][1] == AGENT_TOOLS # tools stay offered
assert llm.requests[1][1] == _OFFER # tools stay offered
def test_grep_capped_at_20_matches_in_catalog_order(
@@ -4370,7 +4392,7 @@ def test_grep_scoped_missing_document_refused(monkeypatch: pytest.MonkeyPatch) -
"No document at 'S/ghost.md' — check the ls output."
)
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
def test_grep_scoped_missing_path_like_doc_gets_did_you_mean(
@@ -4408,7 +4430,7 @@ def test_grep_scoped_missing_path_like_doc_gets_did_you_mean(
)
)
assert holder.tool_calls == 0 and holder.read_docs == [] # a refusal
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
def test_grep_scoped_bare_source_name_refused_without_db(
@@ -4467,7 +4489,7 @@ def test_grep_missing_arguments_refused(
asyncio.run(_run(llm, holder, _settings()))
assert llm.requests[1][0][3]["content"].startswith(agent.MISSING_SEARCH_ARGS)
assert holder.tool_calls == 0 and holder.read_docs == []
assert llm.requests[1][1] == AGENT_TOOLS # rejected → tools stay offered
assert llm.requests[1][1] == _OFFER # rejected → tools stay offered
def test_grep_no_matches_whole_kb(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -4577,7 +4599,7 @@ def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None:
# after round 1, 8 left after round 2 (10-round budget).
assert llm.requests[2][0][3]["content"] == listing + agent.TOOL_BUDGET_REMAINING.format(n=9)
assert llm.requests[2][0][5]["content"] == listing + agent.TOOL_BUDGET_REMAINING.format(n=8)
assert llm.requests[2][1] == AGENT_TOOLS # still offered (no budgets)
assert llm.requests[2][1] == _OFFER # still offered (no budgets)
def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -4604,7 +4626,7 @@ def test_multi_read_executes_without_budgets(monkeypatch: pytest.MonkeyPatch) ->
assert llm.requests[2][0][5]["content"].startswith(
"Document S/b.md:\ndate: 2024-06-15\nB-CONTENT"
)
assert llm.requests[2][1] == AGENT_TOOLS # the second read was still offered
assert llm.requests[2][1] == _OFFER # the second read was still offered
# ---------- round cap (phase 45: replaces the per-tool budgets) ----------
@@ -4632,9 +4654,9 @@ def test_always_ls_bounded_by_round_cap(monkeypatch: pytest.MonkeyPatch) -> None
]
assert len(llm.requests) == 4 # 3 tool rounds + the forced answer
# The three tool rounds were offered the tools…
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[2][1] == AGENT_TOOLS
assert llm.requests[0][1] == _OFFER
assert llm.requests[1][1] == _OFFER
assert llm.requests[2][1] == _OFFER
# …and the forced final request carries no tools, whatever is left.
assert llm.requests[3][1] is None
# Every re-list executed and counted.
@@ -4686,9 +4708,9 @@ def test_rejected_read_spam_runs_to_round_cap(monkeypatch: pytest.MonkeyPatch) -
)
asyncio.run(_run(llm, holder, _settings(agent_max_rounds=3)))
assert len(llm.requests) == 4 # 3 rejected rounds + the forced answer
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[1][1] == AGENT_TOOLS
assert llm.requests[2][1] == AGENT_TOOLS
assert llm.requests[0][1] == _OFFER
assert llm.requests[1][1] == _OFFER
assert llm.requests[2][1] == _OFFER
assert llm.requests[3][1] is None # the forced final request: no tools
assert holder.read_docs == [] and holder.tool_calls == 0 # nothing executed
refusal = "No document at 'S/ghost.md' — check the ls output."
@@ -5101,8 +5123,8 @@ def test_round_retried_before_first_piece(
# The restart is byte-identical: same messages, same tools offered.
assert len(llm.requests) == 3
assert llm.requests[0] == llm.requests[1]
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[2][1] == AGENT_TOOLS # the answer round still offered
assert llm.requests[0][1] == _OFFER
assert llm.requests[2][1] == _OFFER # the answer round still offered
# The flat delay was awaited exactly once, before the retry.
assert sleeps == [2.5]
tool_logs = [r for r in caplog.records if r.getMessage().startswith("agent tool=")]
@@ -5397,7 +5419,7 @@ def test_scaffolding_only_round_gets_exactly_one_recovery(
assert pieces == [StreamPiece("content", "The clean recovery answer.")]
assert len(llm.requests) == 2 # the round + the one recovery
# The round: the original system prompt, the tools offered.
assert llm.requests[0][1] == AGENT_TOOLS
assert llm.requests[0][1] == _OFFER
assert llm.requests[0][0] == [
{"role": "system", "content": "SYSTEM_PROMPT"},
{"role": "user", "content": "QUESTION"},
@@ -5534,7 +5556,7 @@ def test_scaffolding_round_with_tool_calls_needs_no_recovery(
StreamPiece("content", "the answer after the tool"),
]
assert len(llm.requests) == 2
assert llm.requests[1][1] == AGENT_TOOLS # a normal round, not a recovery
assert llm.requests[1][1] == _OFFER # a normal round, not a recovery
for messages, _tools in llm.requests:
assert all(
m["content"] is None or agent.CORRECTION_INSTRUCTION not in m["content"]
@@ -5615,3 +5637,515 @@ def test_forced_final_scaffolding_only_yields_the_budget_fallback(
# calls — this is the fallback path, not the rejection path.
assert holder.rejected_calls == 0
assert holder.tool_calls == 1
# ---------------------------------------------------------------------------
# Phase 182 (task 03) — the 5th agent tool: ``show_text(path, offset?, limit?)``
#
# The display sibling of ``show_image`` (the phase-124 family in
# ``tests/unit/test_image_chips_and_answers.py`` is the template): the model
# NAMES a location, the SERVER shows the bytes (LOCKED A1–A3/A6). The suite
# pins the offer matrix (``agent_tools``, four base + two display tools, each
# behind its own flag), the successful result shape (the ``read``/
# ``show_image`` header contract, the SERVER's extent in the ``lines:``
# header), ``holder.shown_texts`` (the read-after-stream signal task 05 puts
# on the ``done`` frame), and EVERY refusal — each counts in nothing,
# appends nothing, and consumes a round like every other refusal.
# ---------------------------------------------------------------------------
_SHOW_SRC = "showsrc"
_SHOW_PATH = "quadlets/gufo.container"
_SHOW_IDENTITY = f"{_SHOW_SRC}/{_SHOW_PATH}"
_SHOW_CONTENT = "L1-one\nL2-two\nL3-three\nL4-four\nL5-five"
def _show_doc(content: str = _SHOW_CONTENT, *, is_image: bool = False) -> Document:
"""The fixture row ``show_text`` resolves (detached, like every
agent-suite fixture); ``is_image`` flips the A6 refusal case."""
doc = _doc(_SHOW_SRC, _SHOW_PATH, "Gufo quadlet", content)
doc.is_image = is_image
return doc
def _patch_show_doc(
monkeypatch: pytest.MonkeyPatch, doc: Document | None
) -> None:
"""One resolvable document for ``_resolve_path`` (the same
monkeypatch style as the read family: the DB accessors are the seam)."""
def _find(_db: Session, source: str, path: str) -> Document | None:
if doc is not None and (source, path) == (doc.source, doc.path):
return doc
return None
monkeypatch.setattr(agent, "find_document", _find)
def _show_text(
arguments: dict[str, Any],
holder: AgentHolder,
settings: Settings | None = None,
) -> str:
"""One ``show_text`` call through the pure ``_execute_tool`` seam (the
budget trailer is appended by ``run_agent``, never here — the raw
result strings are what this suite pins)."""
return agent._execute_tool(
_mock_session(),
ToolCallPiece(id="c1", name="show_text", arguments=arguments),
[],
holder,
settings or _settings(),
)
# --- the offer matrix (the task-03 completion pin: 4 base + 2 display) ---
def test_show_text_offer_matrix_agent_tools_by_flags() -> None:
"""``agent_tools`` offers FOUR tools with ``answer_text=False`` (the
pre-phase-182 offer byte-identical — ``show_image`` last when
offered) and FIVE with it ON (``show_text`` present, correct order
``ls, read, grep, show_image, show_text``); both flags off is
element-for-element ``AGENT_TOOLS`` — a fresh list, never the
constant."""
pre182 = agent_tools(_settings(answer_images=True, answer_text=False))
assert [t["function"]["name"] for t in pre182] == [
"ls",
"read",
"grep",
"show_image",
]
assert pre182[:3] == AGENT_TOOLS # the base three byte-identical
both = agent_tools(_settings(answer_images=True, answer_text=True))
assert [t["function"]["name"] for t in both] == [
"ls",
"read",
"grep",
"show_image",
"show_text",
]
assert both[:3] == AGENT_TOOLS
pre124 = agent_tools(_settings(answer_images=False, answer_text=False))
assert pre124 == AGENT_TOOLS # element-for-element
assert pre124 is not AGENT_TOOLS # a fresh list, never the constant
pre124.append({"junk": True}) # mutating the offer must not touch it
assert len(AGENT_TOOLS) == 3
# The shipped default: ``answer_text`` is ON (LOCKED A8), so the
# default grounded offer already carries the 5th tool.
default = agent_tools(_settings())
assert [t["function"]["name"] for t in default] == [
"ls",
"read",
"grep",
"show_text",
]
assert default == _OFFER # the module-wide offer pin stays honest
def test_show_text_tool_definition_is_the_behavior_contract() -> None:
"""The same OpenAI function shape as the other tools (one required
string ``path`` + the OPTIONAL ``offset``/``limit`` integers), and
the description IS the behavior contract (the phase-124 discipline):
verbatim display, the do-NOT-retype instruction, the server-owned
extent (clamp + past-end teaching), the ``show_image`` pointer
(A6), the bare-path teaching mirrored from ``read``, and the
one-call-at-a-time discipline."""
assert SHOW_TEXT_TOOL["type"] == "function"
fn = SHOW_TEXT_TOOL["function"]
assert fn["name"] == "show_text"
params = fn["parameters"]
assert params["type"] == "object"
assert params["required"] == ["path"]
props = params["properties"]
assert set(props) == {"path", "offset", "limit"}
assert props["path"]["type"] == "string"
assert props["offset"]["type"] == "integer"
assert props["limit"]["type"] == "integer"
# The ``read`` bare-path teaching reused verbatim (one grammar).
assert (
"A bare document path (without the source name) will not resolve."
in props["path"]["description"]
)
desc = fn["description"]
assert "EXACTLY as stored" in desc
assert "do NOT retype the content yourself" in desc
assert "`offset` (1-based first line) and `limit` (line count)" in desc
assert "overshooting `limit` is clamped" in desc
assert "refused with the true line" in desc
assert "Only text documents — for an image use `show_image`" in desc
assert "Call one tool at a time" in desc
def test_show_text_refusal_texts_are_pinned() -> None:
"""The four refusal texts are pinned (the model's self-correction
relies on stable copy), and the range problems REUSE the read
constants — one copy of each string in the codebase."""
assert MISSING_SHOW_TEXT_ARGS == "show_text requires a string argument 'path'."
assert SHOW_TEXT_NOT_A_TEXT_DOC.format(arg="s/p.png") == (
"'s/p.png' is a standalone image document — use show_image to "
"display images."
)
assert SHOW_TEXT_BLOCK_CAP.format(max=5) == (
"5 text blocks are already displayed this answer — describe them "
"instead of showing more."
)
assert SHOW_TEXT_ALREADY_SHOWN.format(locator="s/p.md#12-40") == (
"s/p.md#12-40 is already on screen this answer — say so instead "
"of showing it again."
)
# The reuse pins: the branch formats THESE constants, not copies —
# the exact same teaching strings (and echoed values) ``read`` uses.
assert agent.BAD_RANGE_ARGS.format(offset=0, limit=None) == (
"`offset` and `limit` are whole line numbers — pass integers ≥ 1 "
"(you passed offset=0, limit=None)."
)
assert agent.OFFSET_PAST_END.format(
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=5, offset=9
).startswith(f"{_SHOW_IDENTITY} is 5 lines long — `offset` 9 is past the end.")
# --- the successful show: whole document, ranged, capped ---
def test_show_text_whole_document_success_shape(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Whole-document show (the taught default): result headers in the
exact ``read``/``show_image`` order (a NEW first line, then
``Document …``, ``date:``, the ``lines:`` extent, the VERBATIM
slice), ``shown_texts`` carries the one block (canonical whole-doc
locator), ``tool_calls`` counts the executed call, and the READ
bookkeeping stays untouched (a show is not a context-adder)."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
result = _show_text({"path": _SHOW_IDENTITY}, holder)
assert result == (
"Text shown to the user — these lines are displayed verbatim; "
"describe them, do NOT retype them.\n"
f"Document {_SHOW_IDENTITY}:\n"
"date: 2024-06-15\n"
"lines: 1-5 of 5\n"
f"{_SHOW_CONTENT}"
)
assert result.splitlines()[1].startswith("Document ") # the mock's contract
assert len(holder.shown_texts) == 1
block = holder.shown_texts[0]
assert block.locator == _SHOW_IDENTITY # whole doc → no range part
assert (block.start_line, block.end_line, block.total_lines) == (1, 5, 5)
assert block.truncated is False
assert holder.tool_calls == 1 # executed-only counting
# A show NEVER touches the read paths (it is not a context-adder).
assert holder.read_docs == []
assert holder.read_coverage == []
assert holder.read_truncations == []
def test_show_text_ranged_show_clamps_limit_in_the_lines_header(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Ranged show with an OVERSHOOTING ``limit``: the header carries the
SERVER's clamped extent (``lines: 4-5 of 5`` — the caption never
lies), the slice is exactly the displayed lines, and the canonical
locator carries the clamped range (the D7 matching key)."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
result = _show_text({"path": _SHOW_IDENTITY, "offset": 4, "limit": 99}, holder)
assert result == (
"Text shown to the user — these lines are displayed verbatim; "
"describe them, do NOT retype them.\n"
f"Document {_SHOW_IDENTITY}:\n"
"date: 2024-06-15\n"
"lines: 4-5 of 5\n"
"L4-four\nL5-five"
)
block = holder.shown_texts[0]
assert block.locator == f"{_SHOW_IDENTITY}#4-5"
assert (block.start_line, block.end_line, block.total_lines) == (4, 5, 5)
assert holder.tool_calls == 1
def test_show_text_capped_block_carries_marker_and_notice(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Capped show (``settings.show_text_max_chars``): the slice is cut
at a LINE boundary, the shared ``TRUNCATION_MARKER`` rides the block,
the ``lines:`` extent SHRINKS to the last displayed line (never the
asked extent), and the pinned notice names that the rest is NOT
displayed and the user opens the document (no re-show loop implied)."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
result = _show_text(
{"path": _SHOW_IDENTITY},
holder,
_settings(show_text_max_chars=13),
)
assert TRUNCATION_MARKER in result
assert result.endswith(
SHOWN_TEXT_TRUNCATION_NOTICE.format(source=_SHOW_SRC, path=_SHOW_PATH)
)
block = holder.shown_texts[0]
assert block.truncated is True
# The budget (13 chars) lands exactly after line 2 — the caption
# shrinks to the lines ACTUALLY shown (never the asked 1-5).
assert block.end_line == 2
assert block.locator == f"{_SHOW_IDENTITY}#1-2" # cap-derived range
assert f"lines: 1-2 of 5\nL1-one\nL2-two\n{TRUNCATION_MARKER}" in result
assert holder.tool_calls == 1 # a capped show is still a successful call
def test_show_text_origin_lines_ridge_the_header_like_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The phase-145 origin contract: a git-sourced show carries the
``origin:``/``branch:`` lines immediately after ``date:`` and BEFORE
``lines:`` (the same ``_origin_lines`` block ``read`` renders; the
second line stays ``Document …:``)."""
doc = _show_doc()
_patch_show_doc(monkeypatch, doc)
def _factory() -> Session:
from app.models import GitSource
rows = [GitSource(url="https://github.com/owner/showsrc", kind="git", branch="main")]
mock = cast("Session", MagicMock())
mock.__enter__ = MagicMock(return_value=mock)
mock.__exit__ = MagicMock(return_value=False)
mock.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=rows)))
return mock
holder = AgentHolder()
result = agent._execute_tool(
_factory(),
ToolCallPiece(id="c1", name="show_text", arguments={"path": _SHOW_IDENTITY}),
[],
holder,
_settings(),
)
lines = result.splitlines()
assert lines[1] == f"Document {_SHOW_IDENTITY}:"
assert lines[2] == "date: 2024-06-15"
assert lines[3] == "origin: https://github.com/owner/showsrc"
assert lines[4] == "branch: main"
assert lines[5] == "lines: 1-5 of 5"
# --- the refusal matrix: each counts in nothing, appends nothing ---
def _assert_refusal(holder: AgentHolder) -> None:
assert holder.tool_calls == 0
assert holder.shown_texts == []
assert holder.read_docs == []
assert holder.read_coverage == []
def test_show_text_missing_or_non_string_path_refused() -> None:
"""Missing, blank and non-string ``path`` → ``MISSING_SHOW_TEXT_ARGS``
(the ``MISSING_SHOW_ARGS`` shape); counts in nothing."""
holder = AgentHolder()
for arguments in ({}, {"path": " "}, {"path": None}, {"path": 42}):
assert _show_text(arguments, holder) == MISSING_SHOW_TEXT_ARGS
_assert_refusal(holder)
def test_show_text_unknown_path_gets_the_did_you_mean_teaching(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The phase-72 teaching, REUSED unchanged: a bare argument keeps the
no-DB line, a path-like argument matching one indexed ``path`` gets
the combined-identity suggestion — nothing displayed either way."""
_patch_show_doc(monkeypatch, None)
doc = _show_doc()
monkeypatch.setattr(
agent,
"find_path_candidates",
lambda db, arg: [(_SHOW_SRC, _SHOW_PATH, doc.title)],
)
holder = AgentHolder()
result = _show_text({"path": "quadlets/gufo.container"}, holder)
assert result == agent.NO_DOCUMENT_DID_YOU_MEAN.format(
arg="quadlets/gufo.container", source=_SHOW_SRC, path=_SHOW_PATH
)
assert _show_text({"path": "nope"}, holder) == (
"No document at 'nope' — check the ls output."
)
_assert_refusal(holder)
def test_show_text_on_an_image_doc_is_refused_never_converted(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""LOCKED A6: a standalone IMAGE row is refused with the
``show_image`` pointer — never silently converted (the exact mirror
of ``show_image``'s ``NOT_AN_IMAGE_DOC``); counts in nothing."""
_patch_show_doc(monkeypatch, _show_doc(is_image=True))
holder = AgentHolder()
result = _show_text({"path": _SHOW_IDENTITY}, holder)
assert result == SHOW_TEXT_NOT_A_TEXT_DOC.format(arg=_SHOW_IDENTITY)
_assert_refusal(holder)
def test_show_text_bad_range_args_reuse_the_read_teaching(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A non-whole/``< 1`` ``offset``/``limit`` → :data:`BAD_RANGE_ARGS`
with the values echoed — the SAME constant the ``read`` branch
formats (one copy of the string), fired before any display."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
assert _show_text({"path": _SHOW_IDENTITY, "offset": 0}, holder) == (
agent.BAD_RANGE_ARGS.format(offset=0, limit=None)
)
assert _show_text({"path": _SHOW_IDENTITY, "offset": 1, "limit": "x"}, holder) == (
agent.BAD_RANGE_ARGS.format(offset=1, limit="x")
)
_assert_refusal(holder)
def test_show_text_offset_past_end_names_the_true_total(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An ``offset`` past the last line → :data:`OFFSET_PAST_END` (the
SAME teaching as ``read``) naming the document's TRUE line count;
counts in nothing, a corrected retry still proceeds."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
result = _show_text({"path": _SHOW_IDENTITY, "offset": 9}, holder)
assert result == agent.OFFSET_PAST_END.format(
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=5, offset=9
)
assert "is 5 lines long" in result
_assert_refusal(holder)
def test_show_text_empty_document_is_refused_like_past_end(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An EMPTY document has nothing to display: the resolver's
past-the-end refusal carries the true total (``0 lines long``) —
never an empty block, never a crash."""
_patch_show_doc(monkeypatch, _show_doc(content=""))
holder = AgentHolder()
result = _show_text({"path": _SHOW_IDENTITY}, holder)
assert result == agent.OFFSET_PAST_END.format(
source=_SHOW_SRC, path=_SHOW_PATH, total_lines=0, offset=1
)
_assert_refusal(holder)
def test_show_text_block_cap_refuses_second_show(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The per-answer block budget (server-enforced): with
``show_text_max_blocks=1`` a SECOND, DIFFERENT show is refused with
the cap copy — the first block stays displayed, the refusal counts
in nothing."""
doc_a = _show_doc()
doc_b = _doc(_SHOW_SRC, "quadlets/other.container", "Other", "B1\nB2")
docs = {doc_a.path: doc_a, doc_b.path: doc_b}
def _find(_db: Session, source: str, path: str) -> Document | None:
return docs.get(path) if source == _SHOW_SRC else None
monkeypatch.setattr(agent, "find_document", _find)
holder = AgentHolder()
settings = _settings(show_text_max_blocks=1)
first = _show_text({"path": _SHOW_IDENTITY}, holder, settings)
assert first.startswith("Text shown to the user —")
second = _show_text({"path": f"{_SHOW_SRC}/quadlets/other.container"}, holder, settings)
assert second == SHOW_TEXT_BLOCK_CAP.format(max=1)
assert holder.tool_calls == 1 # the refusal counts in nothing
assert len(holder.shown_texts) == 1 # still exactly the first block
def test_show_text_zero_knobs_refuse_the_first_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The ``0`` kill switches (D8): ``show_text_max_blocks=0`` refuses
the FIRST call with the cap copy, and ``show_text_max_chars=0`` (the
inert per-block budget — nothing could display) refuses with the very
same copy instead of reaching the resolver's raise. The tool stays
OFFERED either way (the offer is ``answer_text``-gated only); counts
in nothing."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
assert _show_text(
{"path": _SHOW_IDENTITY}, holder, _settings(show_text_max_blocks=0)
) == SHOW_TEXT_BLOCK_CAP.format(max=0)
assert _show_text(
{"path": _SHOW_IDENTITY}, holder, _settings(show_text_max_chars=0)
) == SHOW_TEXT_BLOCK_CAP.format(max=0)
_assert_refusal(holder)
# The offer is untouched by the budgets (the gate is the flag).
assert [t["function"]["name"] for t in agent_tools(
_settings(show_text_max_blocks=0, show_text_max_chars=0)
)] == ["ls", "read", "grep", "show_text"]
def test_show_text_duplicate_identical_show_refused(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The canonical-locator dedupe: showing the EXACT same block twice
is refused (``SHOW_TEXT_ALREADY_SHOWN`` names the locator) — the
first block stays on screen, the refusal counts in nothing."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
first = _show_text({"path": _SHOW_IDENTITY, "offset": 2, "limit": 2}, holder)
assert first.startswith("Text shown to the user —")
second = _show_text({"path": _SHOW_IDENTITY, "offset": 2, "limit": 2}, holder)
assert second == SHOW_TEXT_ALREADY_SHOWN.format(locator=f"{_SHOW_IDENTITY}#2-3")
assert holder.tool_calls == 1
assert len(holder.shown_texts) == 1
def test_show_text_refused_when_answer_text_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Defensive posture: with ``answer_text=False`` the tool was never
OFFERED, so a call anyway is answered like an un-offered name (the
tools-off posture — :data:`UNKNOWN_TOOL`) — counts in nothing,
displays nothing (the escape hatch stays byte-identical everywhere)."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
result = _show_text({"path": _SHOW_IDENTITY}, holder, _settings(answer_text=False))
assert result == UNKNOWN_TOOL
_assert_refusal(holder)
# --- the scripted turn (the completion-criteria wiring pin) ---
def test_show_text_scripted_turn_returns_the_slice_to_the_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Completion pin: with ``answer_text=True`` a scripted agent turn
returns a result whose 2nd line starts ``Document `` and whose
``lines:`` header matches the slice actually returned; the block
rides the holder for the done frame (task 05) and the tool-offer
on the request carried the 5th tool."""
_patch_show_doc(monkeypatch, _show_doc())
holder = AgentHolder()
llm = ScriptedLLM(
[ToolCallPiece(id="c1", name="show_text", arguments={"path": _SHOW_IDENTITY, "offset": 3})],
[StreamPiece("content", "ans")],
)
asyncio.run(_run(llm, holder, _settings())) # answer_text default ON
assert llm.requests[0][1] == _OFFER # the grounded turn offers show_text
content = llm.requests[1][0][3]["content"]
assert content.endswith(agent._budget_note(9)) # the trailer rides too
result = content.removesuffix(agent._budget_note(9))
lines = result.splitlines()
assert lines[0] == (
"Text shown to the user — these lines are displayed verbatim; "
"describe them, do NOT retype them."
)
assert lines[1].startswith("Document ")
assert lines[3] == "lines: 3-5 of 5" # offset-only: to the END, clamped
assert result.endswith("L3-three\nL4-four\nL5-five") # the slice verbatim
assert len(holder.shown_texts) == 1
assert holder.shown_texts[0].locator == f"{_SHOW_IDENTITY}#3-5"
assert holder.tool_calls == 1
+154 -6
View File
@@ -23,7 +23,7 @@ from app.config import Settings
from app.main import app as fastapi_app
from app.models import Document, KbOverview, QueryLog
from app.rag import agent as rag_agent
from app.rag.agent import AGENT_TOOLS
from app.rag.agent import AGENT_TOOLS, SHOW_TEXT_TOOL
from app.rag.llm import StreamPiece, ToolCallPiece
from app.rag.prompts import SUGGEST_INTRO, build_deflect_prompt
from app.rag.retriever import RetrievedChunk, weak_hit_titles
@@ -1135,10 +1135,13 @@ def test_endpoint_grounded_turn_runs_agent_loop_with_tools(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 37: a grounded endpoint turn runs the agent loop — the
single no-tool-call request carries ``AGENT_TOOLS`` (the default
round cap keeps the tools offered), no ``tool`` frames stream, and
the ``done`` event is the plain retrieval shape (the tool-free
answer is byte-identical)."""
single no-tool-call request carries the offered tool list (the
default round cap keeps the tools offered), no ``tool`` frames
stream, and the ``done`` event is the plain retrieval shape (the
tool-free answer is byte-identical). Phase 182 (task 03) pin
update, deliberate: the offer is the three base tools plus
``show_text`` (``answer_text`` defaults ON), the base three staying
element-for-element ``AGENT_TOOLS`."""
_session, llm = gate_env
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
@@ -1149,7 +1152,12 @@ def test_endpoint_grounded_turn_runs_agent_loop_with_tools(
assert frames[-1]["deflected"] is False
assert not any(f["type"] == "tool" for f in frames)
assert len(llm.seen) == 1
assert llm.seen_tools == [AGENT_TOOLS] # one request, tools offered
# Phase 182 (task 03): ``answer_text`` defaults ON — the offer is
# the base three (byte-identical) plus ``show_text``.
assert llm.seen_tools == [[*AGENT_TOOLS, SHOW_TEXT_TOOL]] # one request
offered = llm.seen_tools[0]
assert offered is not None
assert offered[:3] == AGENT_TOOLS # the base offer untouched
# The system prompt is the HIGH prompt with the <tools> instructions
# (phase 70: the harness-aligned ls/read/grep copy — new names in,
# old phase-37/68 names out).
@@ -1600,6 +1608,9 @@ def test_endpoint_log_line_records_suggested_after_summary_hits(
"total_ms=",
"retries=",
"scaffold_stripped=",
# phase 182 (task 05): the PLAN §9 additive tail — the blocks
# actually SENT this turn, last on the line.
"verbatim_blocks=",
)
idx = -1
for field in order:
@@ -1669,3 +1680,140 @@ def test_endpoint_log_line_records_suggested_after_summary_hits(
line = _last_log_line()
assert "thinking=- thinking_chars=" in line
assert "thinking=high" not in line
# ---------------------------------------------------------------------------
# Phase 182 (task 05): the per-turn log line's ``verbatim_blocks=N`` tail
# and the deflected turn's resolve-noting posture (D9). The count is the
# blocks ACTUALLY SENT (after the canonical-locator dedupe and the
# ``show_text_max_blocks`` cut), so the resolver is faked here — its own
# behaviour is pinned in ``tests/unit/test_verbatim_resolver.py`` and the
# real end-to-end fusion in ``tests/integration/test_chat_api.py``.
# ---------------------------------------------------------------------------
def _block(n: int) -> Any:
"""One fake resolved block (the resolver's return type — the counting
and the frame wiring are what these pins are about)."""
from app.rag.verbatim import VerbatimBlock
return VerbatimBlock(
source="Homelab",
path=f"quadlet/gufo.container{n}",
title=f"gufo{n}",
start_line=1,
end_line=4,
total_lines=12,
text=f"[Unit{n}\nExec=gufo",
truncated=False,
locator=f"Homelab/quadlet/gufo.container{n}#1-4",
)
def test_log_line_ends_with_verbatim_blocks_zero_on_a_plain_turn(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A plain grounded turn (no tool blocks, no fence in the answer)
ends ``… scaffold_stripped=0 verbatim_blocks=0`` — the PLAN §9 field
is UNIFORM across turn shapes (the phase-67 ``retries=N`` pattern),
and the ``done`` frame carries no ``answer_text`` key (A9)."""
_session, _llm = gate_env
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)]))
with caplog.at_level("INFO", logger="app.chat"):
frames = _ask(client, "How is my Kubernetes cluster set up?")
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
assert lines
assert lines[-1].endswith("scaffold_stripped=0 verbatim_blocks=0")
done = [f for f in frames if f.get("type") == "done"]
assert done and "answer_text" not in done[0]
def test_log_line_counts_the_verbatim_blocks_sent(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Two resolved blocks → ``verbatim_blocks=2`` AND both ride the
``done`` frame's ``answer_text`` in the resolver's order (the frame
and the line count the SAME list — never the raw tool-call count)."""
_session, _llm = gate_env
doc = _doc("Kubernetes Homelab Cluster", "TALOS_DOC_SENT")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.30)]))
monkeypatch.setattr(
chat_api, "resolve_answer_blocks", lambda *_a: [_block(1), _block(2)]
)
with caplog.at_level("INFO", logger="app.chat"):
frames = _ask(client, "How is my Kubernetes cluster set up?")
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
assert lines and lines[-1].endswith("verbatim_blocks=2")
done = [f for f in frames if f.get("type") == "done"]
assert done
assert done[0]["answer_text"] == [
{
"source": "Homelab",
"path": "quadlet/gufo.container1",
"title": "gufo1",
"start_line": 1,
"end_line": 4,
"total_lines": 12,
"text": "[Unit1\nExec=gufo",
"truncated": False,
"locator": "Homelab/quadlet/gufo.container1#1-4",
},
{
"source": "Homelab",
"path": "quadlet/gufo.container2",
"title": "gufo2",
"start_line": 1,
"end_line": 4,
"total_lines": 12,
"text": "[Unit2\nExec=gufo",
"truncated": False,
"locator": "Homelab/quadlet/gufo.container2#1-4",
},
]
def test_deflected_turn_resolves_nothing_and_logs_zero(
client: TestClient,
gate_env: tuple[_FakeSession, _CannedLLM],
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""D9: a DEFLECTED turn resolves NOTHING even when its own answer
text carries a `` ```bor `` fence — a hallucinated locator must not
leak KB text through the turn that just disclaimed it. The resolver
is never called, the line reads ``verbatim_blocks=0`` and the frame
carries no ``answer_text`` key."""
_session, llm = gate_env
llm.answer = (
"I haven't done anything like that.\n"
"```bor Homelab/quadlet/gufo.container 1-4\n"
"Exec=/usr/bin/lie\n"
"```"
)
doc = _doc("Sourdough Starter", "BREAD_DOC_SENT")
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.10)]))
calls: list[Any] = []
monkeypatch.setattr(
chat_api,
"resolve_answer_blocks",
lambda *_a: calls.append(1) or [], # pyright: ignore[reportFunctionMemberAccess]
)
with caplog.at_level("INFO", logger="app.chat"):
frames = _ask(client, "How do I bake sourdough bread?")
lines = [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
assert lines and lines[-1].endswith("verbatim_blocks=0")
assert calls == [] # the resolver never ran on a deflected turn
done = [f for f in frames if f.get("type") == "done"]
assert done and done[0]["deflected"] is True
assert "answer_text" not in done[0]
+6 -2
View File
@@ -113,7 +113,9 @@ def test_raw_text_only_stored_and_re_rendered_on_restore() -> None:
# per-call behavior override (default SCROLL instead of forced
# "auto" — documented at the call site).
assert 'addMessage("user", renderMarkdown(m.text), true)' in js
assert 'addMessage("brain", renderMarkdown(m.text), true)' in js
# Phase 182 (task 07): the brain restore renderer opts INTO the verbatim
# pass (the user renderer does not — a question is never a locator).
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in js
assert "wrap.classList.add(\"is-deflected\")" in js
assert "appendMaybeTry(wrap, m.suggestions)" in js
assert "appendSources(wrap, m.sources)" in js
@@ -289,7 +291,9 @@ def test_brain_turn_persists_optional_thinking_field() -> None:
'const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "")'
in branch
)
assert "renderMarkdown(finalText)" in branch, (
# Phase 182 (task 07): the fallback renders through the SAME opt-in as
# the per-delta write (ONE answer renderer, one shape).
assert "renderMarkdown(finalText, { verbatim: true })" in branch, (
"the substituted fallback must render into the bubble"
)
+70
View File
@@ -426,6 +426,76 @@ def test_read_max_chars_rejects_negative(monkeypatch: pytest.MonkeyPatch) -> Non
_settings()
def test_answer_text_default_true_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 182 (LOCKED A8): the verbatim-display master switch defaults
to ON — an answer whose code block silently differs from the file is a
correctness bug, so the fix ships on and the switch is an escape hatch
(unlike phase 124's ``answer_images``, which defaults OFF). Env-tunable
in both directions; ``0``/``false`` is the byte-identical pre-phase
state (no tool, no clause, no resolution, no ``answer_text`` key)."""
monkeypatch.delenv("BOR_ANSWER_TEXT", raising=False)
assert _settings().answer_text is True
for off in ("0", "false"):
monkeypatch.setenv("BOR_ANSWER_TEXT", off)
assert _settings().answer_text is False
for on in ("1", "true"):
monkeypatch.setenv("BOR_ANSWER_TEXT", on)
assert _settings().answer_text is True
def test_show_text_max_chars_default_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 182 (LOCKED A8): one displayed verbatim slice is capped at
``BOR_SHOW_TEXT_MAX_CHARS`` (default 12 000 chars — 5 blocks is a 60 KB
worst case against the 256 KB response budget). ``0`` is accepted and
means INERT (no block displays) — not a startup error."""
monkeypatch.delenv("BOR_SHOW_TEXT_MAX_CHARS", raising=False)
assert _settings().show_text_max_chars == 12_000
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "500")
assert _settings().show_text_max_chars == 500
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "0")
assert _settings().show_text_max_chars == 0
def test_show_text_max_chars_rejects_negative(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A negative per-block cap is a typo — it would slice from the END of
the content instead of failing, so startup refuses naming the field
(the ``read_max_chars`` pattern, phase 182)."""
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_CHARS", "-1")
with pytest.raises(ValidationError, match="show_text_max_chars"):
_settings()
def test_show_text_max_blocks_default_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Phase 182 (LOCKED A8): at most ``BOR_SHOW_TEXT_MAX_BLOCKS`` verbatim
blocks display per answer (default 5, counted in answer order). ``0``
is accepted and means INERT (no block displays)."""
monkeypatch.delenv("BOR_SHOW_TEXT_MAX_BLOCKS", raising=False)
assert _settings().show_text_max_blocks == 5
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "1")
assert _settings().show_text_max_blocks == 1
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "0")
assert _settings().show_text_max_blocks == 0
def test_show_text_max_blocks_rejects_negative(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A negative per-answer block cap is a typo — it would count down
from the END of the block list instead of failing, so startup refuses
naming the field (the ``agent_max_rounds`` pattern, phase 182)."""
monkeypatch.setenv("BOR_SHOW_TEXT_MAX_BLOCKS", "-1")
with pytest.raises(ValidationError, match="show_text_max_blocks"):
_settings()
def test_recency_boost_default_and_env_override(
monkeypatch: pytest.MonkeyPatch,
) -> None:
+4 -1
View File
@@ -887,7 +887,10 @@ def test_renderer_extracted_to_shared_markdown_js() -> None:
and BOTH pages load it via a relative <script src> before their module
scripts — so the globals exist when app.js/document.js run."""
md = _read(MARKDOWN_JS)
assert "function renderMarkdown(md)" in md
# Phase 182 (task 06) added the SECOND, optional `opts` argument — the
# renderer still lives here exactly once, which is what this pin is
# about (the argument is pinned by tests/unit/test_frontend_verbatim_markdown.py).
assert "function renderMarkdown(md" in md
assert "function escapeHtml(s)" in md
app_js = _read(APP_JS)
+72 -6
View File
@@ -982,6 +982,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
turnAbort?.abort(); // phase 48: same outcome, one owner — aborted is
// set first, so the catch never reads the guard's
// abort as a user stop
// Phase 182 (task 07): the 300 s guard is a terminal frame — a panel
// still reading "Retrieving the exact text…" would be a lie by then
// (the hydrate at `done` never runs), so settle it to the honest state.
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
setUiState(UI_STATE.error, "That's taking a long time — the answer may be stuck.");
});
@@ -1283,7 +1287,14 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// NEW run in the container — the D15 re-open of the shared
// block is gone). Idempotent — a no-op without an active run.
closeCurrentRun();
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc);
// Phase 182 (task 07): the OPT-IN verbatim pass on the ONE answer
// renderer. A ` ```bor ` locator fence becomes a pending placeholder
// here (the model's re-typed body DROPPED, locked A7) that the `done`
// branch below hydrates against the frame's server-resolved blocks —
// mid-stream the reader sees "Retrieving the exact text…", never a
// copy that might be wrong. Every other renderer on the page (user
// bubbles, thinking runs, the document viewer) stays one-argument.
wrap.querySelector(".bubble").innerHTML = renderMarkdown(acc, { verbatim: true });
// No page scroll (phase 42): the answer never follows the viewport.
} else if (ev.type === "done") {
sawDone = true;
@@ -1315,6 +1326,25 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// figure is additive — "summaries should still be provided");
// a frame without it renders byte-identically (no figure DOM).
if (ev.answer_image) appendAnswerImage(wrap, ev.answer_image);
// Phase 182 (task 07, locked A1/A2): the answer's verbatim blocks —
// `ev.answer_text`, the bytes the SERVER read out of the index — land
// in the SAME band as the answer image (below the text, above the
// source clusters): hydrate every placeholder the renderer painted,
// then append the blocks the answer never located (a `show_text` call
// the prose never named). A locator the server could not resolve
// renders the honest unresolved sentence with NONE of the model's
// bytes, and no panel is left at "Retrieving the exact text…".
hydrateVerbatim(
wrap.querySelector(".bubble"),
ev.answer_text || [],
VERBATIM_LINK_OPTS
);
appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS);
// The turn SETTLED — this is where the ordinary code blocks get their
// Copy buttons (never mid-stream: per-delta the bubble is rebuilt from
// scratch, and a button that vanishes on the next token is not a
// control). NO badge, NO caption on those (locked A5).
enhanceAnswerCodeBlocks(wrap);
appendSources(wrap, ev.sources);
// Phase 113 (task 02): the cited tier stays the citation
// surface (the chips above); the related tier (scored docs
@@ -1332,7 +1362,11 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
// Phase 182 (task 07): the same opt-in as the per-delta write —
// this line paints the answer the user keeps (the fallback text,
// so no placeholder exists in practice, but the ONE answer
// renderer must stay ONE code path in shape).
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText, { verbatim: true });
}
// Persistence save point 2: the answer lands only when the turn is
// complete (raw text + the done metadata; phase 17: + optional
@@ -1359,6 +1393,13 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// the precedent): an image-less / pre-phase turn stores a
// byte-identical record.
answer_image: ev.answer_image || undefined,
// Phase 182 (task 07): the verbatim blocks persist with the turn
// (task 05's additive `answer_text` key) so a reload — and a shared
// page — re-renders the identical panel. `undefined` drops the key
// from the JSON (the house optional-meta pattern, the answer_image
// line above is the precedent): a pre-phase or answer_text=False
// record stays byte-identical.
answer_text: ev.answer_text || undefined,
// Phase 113 (task 02): the related tier persists with the
// turn (undefined drops the key from the JSON — the house
// optional-meta pattern), so the restore path re-renders the
@@ -1431,6 +1472,14 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
}
}
} else if (ev.type === "error") {
// Phase 182 (task 07): an `error` frame is terminal — the `done`
// hydrate will never run, so a placeholder that streamed in stays at
// "Retrieving the exact text…" forever unless it settles HERE (the
// honest unresolved state, never a stale promise of the exact text).
settleVerbatimPlaceholders(
wrap?.querySelector(".bubble"),
"unverified"
);
// Phase 114 (TODO L6): carry the frame's optional hint (the
// "question too long" frame has one) through the throw — the
// banner shows it in place of the default reachability hint.
@@ -1485,6 +1534,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
}
} catch (err) {
if (aborted) {
// Phase 182 (task 07): defensive settle for the guard's own abort
// surface (the timeout callback already ran it — idempotent, and the
// turn must not leave a pending panel on ANY terminal path).
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
// The 300s guard already took the turn to the error state — its
// own abort surfaces here, and it is never read as a user stop.
// Phase 159 (task 03): the aborted settle must not strand the
@@ -1505,6 +1558,10 @@ PINNED_RUN_TURN = """function runTurn(text, { reask = false, image = null } = {}
// convention; no sources/suggestions — the turn never settled).
// The persistedOnLeave guard (phase 20) keeps a navigate-away —
// which aborts the same fetch — from saving the partial twice.
// Phase 182 (task 07): the STOP is terminal for this answer too — a
// placeholder in the kept partial settles to the honest state (the
// blocks never arrive without a `done` frame).
settleVerbatimPlaceholders(wrap?.querySelector(".bubble"), "unverified");
if (wrap && acc && !persistedOnLeave) {
// Phase 158 (D5): the turn was stopped — fold every open run
// (replaces the phase-117 closeToolCalls fold + the D15 block
@@ -1635,14 +1692,23 @@ def test_run_turn_is_pinned_at_phase_159_state() -> None:
list-of-objects no longer stringifies to ``[object Object]``; the
task-03 pre-stream throw carries ``bubbleText`` (FAILED_REQUEST_TEXT)
+ the phase-114 ``hint``, and the catch forwards ``bubbleText:
err?.bubbleText`` into the failed-turn funnel — locked A3): the
full source must match the pin — the ``reask`` path, the turn-local
err?.bubbleText`` into the failed-turn funnel — locked A3)
and phase 182 (task 07)'s sanctioned additions (the two answer
renderers pass the verbatim opt-in — the per-delta write and the
``done``-time re-render; the done settle hydrates the `` ```bor ``
placeholders against ``ev.answer_text``, appends the blocks the answer
never located, and gives the settled answer's ordinary code blocks their
Copy buttons BEFORE ``appendSources``; the ``answer_text`` meta key rides
``rememberBrainTurn`` with ``|| undefined``; and the terminal paths — the
``error`` frame, the 300 s guard, its own abort surface, and the Stop
branch — settle any leftover placeholder to the unresolved state):
the full source must match the pin — the ``reask`` path, the turn-local
resets, the
SSE contract, the save points, and the sanctioned additions (the
task-01 ``lastUserWrap`` assignment + the idle-settle mark
pass, the task-03 aborted-settle mark pair, the phase-160
done-settle pair, the phase-161 live-container additions, and
the D5 refinement) included."""
done-settle pair, the phase-161 live-container additions, the
D5 refinement, and the phase-182 verbatim settle/hydrate pair) included."""
assert _fn_source(_js(), "runTurn") == PINNED_RUN_TURN
+5 -1
View File
@@ -173,7 +173,11 @@ def test_restore_landing_is_one_shot() -> None:
js = _js()
body = _fn_body(js, "renderStoredMessage")
assert 'addMessage("user", renderMarkdown(m.text), true)' in body
assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
# Phase 182 (task 07): the brain renderer gained the verbatim option —
# the SCROLL intent (the trailing `true`) is untouched, and the option
# object (`{ verbatim: true }`) never ends in `", true)"`, so the count
# below still counts the two restore landings alone.
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in body
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
# Submit reveal + the two restore landings — nothing else scrolls.
# Phase 123 (task 02): the submit call is multi-line (the optional
+867
View File
@@ -0,0 +1,867 @@
"""Unit: the verbatim panel client and the answer's Copy buttons (phase 182, task 07).
The browser half of the story lives in the phase's Playwright suite (task
08); this module pins the SOURCE contract of ``frontend/assets/verbatim.js``
and the three client surfaces that consume it, in the established
``test_frontend_*`` convention — plus a node-driven half (skipped when node
is unavailable, the phase-44/138/180/182 idiom) that runs the panel's PURE
logic (caption, gutter, locator match, block guard) against the real file.
Why these particular pins (the phase's drift risks, in order):
* **ONE locator grammar.** ``markdown.js::borLocator`` is the client's single
normalizer (task 06); hydration may only compare the server's canonical
``locator`` against the already-normalized ``data-bor-locator`` with a
plain string ``===``. A second grammar in a second file is the phase's main
way to break silently — so this module pins that no normalizer exists in
``verbatim.js`` (no ``borLocator``, no range regex, no ``parseInt``).
* **The model's bytes never reach the screen or the clipboard.** The panel's
text node is ``pre.textContent = block.text`` (server bytes, verbatim) and
the Copy button copies the same string — never the DOM, never the gutter,
never the model's re-typed fence body (which task 06 dropped upstream).
* **The claim is carried by words, not colours** (LOCKED A5): the badge text
is ``VERBATIM``, the honest state is the pinned sentence ``I could not
verify this against the knowledge base``, and an ordinary code block gets a
Copy button with NO badge.
* **Parity.** The live turn, the reload restore and the anonymous shared page
call the SAME globals, in the SAME order (hydrate → append the unlocated →
settle the code blocks, all inside the ``answer_image`` band and BEFORE the
source clusters), so one answer cannot render three different ways.
* **No panel is ever left lying.** Every terminal path — the ``error`` frame,
the 300 s guard, the Stop action, the failed-turn funnel — settles the
leftover placeholders; nothing reads "Retrieving the exact text…" after the
turn is over.
* **CSP + the house XSS rule.** Everything is ``createElement`` +
``textContent``, every handler is ``addEventListener``, and there is no
``innerHTML``/``onclick=``/``oncopy=`` anywhere in the new client.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
VERBATIM_JS = ASSETS / "verbatim.js"
APP_JS = ASSETS / "app.js"
SHARED_JS = ASSETS / "shared.js"
DOCUMENT_JS = ASSETS / "document.js"
MARKDOWN_JS = ASSETS / "markdown.js"
STYLES_CSS = ASSETS / "styles.css"
INDEX_HTML = FRONTEND / "index.html"
SHARED_HTML = FRONTEND / "shared.html"
CONTAINERFILE = FRONTEND.parent / "Containerfile"
HAVE_NODE = shutil.which("node") is not None
#: The labelled phase-182 CSS region (task 06's markers — task 07 extends
#: INSIDE it, so the palette-only and contiguity pins keep holding).
BLOCK_BEGIN = "/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------"
BLOCK_END = "/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */"
#: The honest state (pinned copy — task 07's Testing section).
UNRESOLVED_COPY = "I could not verify this against the knowledge base"
#: The badge's WORD (LOCKED A5) and the tooltip that explains it.
BADGE_TEXT = "VERBATIM"
BADGE_TITLE = "These bytes are copied from the knowledge base, not written by the model."
#: The Copy control's accessible name template — the FULL path, never a
#: clipped stand-in (the phase-156 clipped-label rule).
COPY_ARIA = "Copy ${shown} lines from ${identity}"
#: The globals task 07 exposes for the two page modules.
PUBLIC_GLOBALS = (
"buildVerbatimPanel",
"hydrateVerbatim",
"appendUnlocatedVerbatim",
"settleVerbatimPlaceholders",
"enhanceAnswerCodeBlocks",
"copyTextWithFallback",
)
def _text(path: Path) -> str:
assert path.is_file(), f"missing file: {path}"
return path.read_text(encoding="utf-8")
def _strip_comments(text: str) -> str:
"""Comments out — the CSP and literal pins below are about CODE. The
file's own header documents the very patterns it forbids (``onclick=``,
``javascript:``), and a prose-blind grep would flag the documentation of
the rule as the rule."""
text = re.sub(r"/\*[\s\S]*?\*/", "", text)
return re.sub(r"(?m)^\s*//.*$", "", text)
def _fn(js: str, name: str) -> str:
"""The source of a top-level (optionally ``async``) ``function <name>``."""
start = js.find(f"function {name}(")
assert start != -1, f"{name}() must exist"
return js[start : js.find("\n}\n", start) + 4]
def _body(js: str, name: str) -> str:
return _fn(js, name)
# ---------------------------------------------------------------------------
# the client ships, and ships in the right shape
# ---------------------------------------------------------------------------
def test_verbatim_js_is_a_classic_script_after_markdown_on_both_pages() -> None:
"""Both pages load it as a PLAIN ``<script>`` immediately AFTER
``markdown.js`` (whose placeholders it hydrates) and BEFORE their page
module (which calls it as a global). Absolute paths on ``shared.html``
(the nested ``/shared/<token>`` route breaks a relative ref), relative on
``index.html`` exactly like its neighbours. No ``type="module"`` — a
module would defer past the pages' boot and expose no globals."""
index = _text(INDEX_HTML)
shared = _text(SHARED_HTML)
assert '<script src="assets/verbatim.js"></script>' in index
assert '<script src="/assets/verbatim.js"></script>' in shared
assert index.index('src="assets/markdown.js"') < index.index('src="assets/verbatim.js"'), (
"verbatim.js hydrates markdown.js's placeholders — it loads after it"
)
assert index.index('src="assets/verbatim.js"') < index.index(
'type="module" src="/assets/app.js"'
), "the globals must exist before app.js evaluates"
assert shared.index('src="/assets/markdown.js"') < shared.index('src="/assets/verbatim.js"')
assert shared.index('src="/assets/verbatim.js"') < shared.index(
'type="module" src="/assets/shared.js"'
)
tag = next(line for line in index.splitlines() if "verbatim.js" in line and "<script" in line)
assert 'type="module"' not in tag, "a classic script — the globals are the API"
# The document viewer never loads it (a file's own ```bor fence is text).
document_html = _text(FRONTEND / "document.html")
assert "verbatim.js" not in document_html
def test_containerfile_produces_it_as_a_minified_classic_script() -> None:
"""Stage 1 builds it the way it builds ``markdown.js``: ``--minify``
WITHOUT ``--bundle`` (bundling a classic script would break the global
exposure), and the source itself stays free of top-level
``import``/``export``."""
container = _text(CONTAINERFILE)
line = next(
row for row in container.splitlines() if "assets/verbatim.js" in row
)
assert "--minify" in line
assert "--outfile=/out/assets/verbatim.js" in line
assert "--bundle" not in line, "a classic script is never bundled"
assert "--format=esm" not in line
js = _text(VERBATIM_JS)
assert "\nimport " not in js and "\nexport " not in js
def test_every_public_global_exists() -> None:
"""The API the two page modules call, as top-level function declarations
(the ``markdown.js`` exposure pattern — a classic script's top-level
functions ARE the globals)."""
js = _text(VERBATIM_JS)
for name in PUBLIC_GLOBALS:
assert f"function {name}(" in js, f"verbatim.js must expose {name}"
def test_no_locator_normalizer_is_re_implemented() -> None:
"""THE phase's drift pin: hydration compares the server's canonical
``locator`` against the ``data-bor-locator`` markdown.js already
normalized, with a PLAIN string compare. No grammar here — no
``borLocator``, no range regex, no en-dash handling, no ``parseInt``, no
``L``-prefix stripping (a second grammar in a second file is how a
resolved block stops hydrating)."""
js = _text(VERBATIM_JS)
assert "function borLocator" not in js
assert "parseInt" not in js
assert "\u2013" not in _fn(js, "matchVerbatimBlock"), "no en-dash parsing here"
for pattern in ("[Ll]", "L?\\d", "#L", "trimStart", "normalize("):
assert pattern not in js, f"{pattern} looks like a re-implemented grammar"
match = _fn(js, "matchVerbatimBlock")
assert "block.locator === key" in match, "the exact match is a plain string compare"
hydrate = _fn(js, "hydrateVerbatim")
assert 'node.getAttribute("data-bor-locator")' in hydrate, (
"hydration reads the attribute markdown.js wrote — it never re-derives it"
)
# ---------------------------------------------------------------------------
# the panel's contract (LOCKED A2/A5/A7)
# ---------------------------------------------------------------------------
def test_panel_is_a_labelled_group_keyed_by_the_servers_locator() -> None:
"""``role="group"`` + ``aria-label="Verbatim text from <path>, lines
N\u2013M"`` + ``data-bor-locator`` = the SERVER's canonical locator (the
key ``appendUnlocatedVerbatim`` later recognises, which is why a
re-render never duplicates a panel)."""
panel = _fn(_text(VERBATIM_JS), "buildVerbatimPanel")
assert 'root.setAttribute("role", "group")' in panel
assert 'root.setAttribute("aria-label", verbatimPanelLabel(block))' in panel
assert "root.dataset.borLocator" in panel
assert "block.locator" in panel, "the server's locator is the panel's identity"
label = _fn(_text(VERBATIM_JS), "verbatimPanelLabel")
assert '"Verbatim text from' in json.dumps(label) or "Verbatim text from" in label
def test_badge_is_a_word_with_the_pinned_explanation() -> None:
"""LOCKED A5: the claim is the WORD ``VERBATIM`` (never an icon or a
colour), with the tooltip that says what it means."""
js = _text(VERBATIM_JS)
assert f'badge.textContent = "{BADGE_TEXT}"' in js
assert BADGE_TITLE in js
assert 'badge.className = "verbatim-badge"' in js
def test_unresolved_state_is_the_pinned_sentence_and_nothing_else() -> None:
"""The honest state for a locator the server did not resolve: the pinned
sentence, plus the document link ONLY when the identity parsed into a
``source/path``. NO badge, NO body, NONE of the model's bytes — and the
builder creates neither a badge nor a ``<pre>`` node at all, which is
stronger than hiding one."""
js = _text(VERBATIM_JS)
assert f'"{UNRESOLVED_COPY}"' in js
node = _fn(js, "buildVerbatimUnresolved")
assert 'root.className = "verbatim verbatim-unresolved"' in node
assert 'state.className = "verbatim-state"' in node
assert "verbatim-badge" not in node, "an unresolved panel never badges"
assert 'createElement("pre")' not in node, "and never has a body"
assert 'createElement("ol")' not in node, "nor a gutter"
assert "indexOf(" in node and "slash > 0" in node, (
"the link appears only when the identity split into a source and a path"
)
def test_copy_button_names_the_full_path_and_copies_the_servers_bytes() -> None:
"""The completion criterion at source level: the panel's Copy control is
built from ``block.text`` (never from the DOM, never with the gutter's
digits), and its accessible name is ``Copy N lines from <source/path>`` —
the full path, because the visible label is the CSS clip target
(phase 156)."""
js = _text(VERBATIM_JS)
panel = _fn(js, "buildVerbatimPanel")
assert COPY_ARIA in panel
build = panel[panel.index("buildCopyButton(") :]
assert '"verbatim-copy"' in build
assert "block.text," in build.split("\n)")[0], (
"the copied string is `block.text` — the exact bytes the panel shows"
)
control = _fn(js, "buildCopyButton")
assert 'btn.setAttribute("aria-label", ariaLabel)' in control
assert 'label.textContent = "Copy"' in control
assert 'label.textContent = "Copied"' in control, "the state is TEXT (WCAG 1.4.1)"
assert 'btn.classList.add("copied")' in control
clipboard = _fn(js, "copyTextWithFallback")
assert "await navigator.clipboard.writeText(text)" in clipboard, (
"the caller's string goes to the clipboard untouched — no DOM read"
)
def test_body_is_the_servers_text_with_the_servers_line_numbers() -> None:
"""The bounded body: the ``<ol>`` gutter (``aria-hidden`` — the range
rides the panel's label, and the digits are ``user-select: none`` in CSS
so a selection never drags them into the clipboard) and the ``<pre>``
whose ``textContent`` is ``block.text``, byte for byte."""
js = _text(VERBATIM_JS)
panel = _fn(js, "buildVerbatimPanel")
assert 'body.className = "verbatim-body"' in panel
assert 'numbers.className = "verbatim-lines"' in panel
assert 'numbers.setAttribute("aria-hidden", "true")' in panel
assert 'pre.className = "verbatim-text"' in panel
assert "pre.textContent = block.text" in panel
assert "li.textContent = row.number" in panel
gutter = _fn(js, "verbatimGutter")
assert 'split("\\n")' in gutter, "one gutter row per line of the SERVER text"
def test_truncated_block_says_the_rest_is_in_the_document() -> None:
"""A capped block appends ONE muted line plus the same viewer link — the
panel never pretends the slice is the whole file."""
panel = _fn(_text(VERBATIM_JS), "buildVerbatimPanel")
assert "if (block.truncated)" in panel
assert 'more.className = "verbatim-more"' in panel
assert "the rest is in the document" in panel
assert "buildVerbatimOpenLink(" in panel.split("if (block.truncated)")[1]
def test_open_link_is_a_real_href_with_the_modal_hook() -> None:
"""The panel links to the document viewer: an always-same-origin
``/document.html?source=…&path=…`` built with ``encodeURIComponent`` (so a
path can never turn into ``javascript:``/``data:``), plus the chat page's
click-to-modal hook through ``opts.onOpen`` — attached with
``addEventListener``, never an inline attribute."""
js = _text(VERBATIM_JS)
link = _fn(js, "buildVerbatimOpenLink")
assert 'link.className = "verbatim-open"' in link
assert "link.addEventListener(" in link
assert "event.preventDefault()" in link
assert "opts.onOpen(source, path, link)" in link
href = _fn(js, "verbatimDocumentHref")
assert "/document.html?source=" in href
assert "encodeURIComponent(source)" in href and "encodeURIComponent(path)" in href
code = _strip_comments(js)
assert "javascript:" not in code and "data:" not in code, (
"no scheme is ever assembled into a link target"
)
def test_plain_answer_code_blocks_get_copy_and_never_a_badge() -> None:
"""``enhanceAnswerCodeBlocks``: every ``<pre>`` in an answer bubble that
is NOT inside a ``.verbatim`` gets ``.code-block`` + ``.code-block-copy``.
NO badge and NO caption — verbatim-and-badged OR plainly the model's,
never a hybrid (LOCKED A5). Idempotent: a ``<pre>`` already inside a
``.code-block`` is left alone."""
fn = _fn(_text(VERBATIM_JS), "enhanceAnswerCodeBlocks")
assert 'box.className = "code-block"' in fn
assert '"code-block-copy"' in fn
assert 'pre.closest(".verbatim")' in fn, "a panel owns its own Copy button"
assert 'parent.classList.contains("code-block")' in fn, "idempotent on re-render"
assert "verbatim-badge" not in fn
assert "verbatim-caption" not in fn
def test_hydration_is_idempotent_and_never_throws_on_garbage() -> None:
"""Only ``.verbatim-pending`` is replaced, so a hydrated panel (or one
restored from storage) survives a re-run with its ``Copied`` state and
scroll position; a missing/malformed ``blocks`` settles to the honest
state instead of throwing."""
js = _text(VERBATIM_JS)
hydrate = _fn(js, "hydrateVerbatim")
assert ".verbatim-pending[data-bor-locator]" in hydrate
assert (
"verbatim-unresolved"
not in hydrate.split("matchVerbatimBlock")[1].split("replaceWith")[1].split("\n")[0]
), "the unresolved node comes from its own builder"
assert "replaceWith(" in hydrate
assert "try {" not in hydrate, "the guards make it total — no exception path"
assert "verbatimBlocks(blocks)" in hydrate
guard = _fn(js, "verbatimBlocks")
assert "Array.isArray(blocks)" in guard, "a missing frame key is not an error"
def test_unlocated_blocks_land_in_the_answer_image_band() -> None:
"""The blocks no placeholder matched (a ``show_text`` call the prose never
named) append to ``.msg-body`` — the same band as the answer image, below
the text and above the source clusters, in frame order — and a block
already placed (a panel carrying its canonical locator) is never
appended twice."""
fn = _fn(_text(VERBATIM_JS), "appendUnlocatedVerbatim")
assert 'wrap.querySelector(".msg-body")' in fn
assert "host.appendChild(buildVerbatimPanel(block, opts))" in fn
assert "placed.add(" in fn and "if (placed.has(key)) continue" in fn
assert "verbatim-unresolved" in fn, (
"an unresolved placeholder is NOT a placement (the block still belongs on screen)"
)
def test_terminal_settle_renders_the_honest_state() -> None:
"""``settleVerbatimPlaceholders`` turns every leftover ``.verbatim-pending``
into the unresolved node and records the ``kind`` for the next reader."""
js = _text(VERBATIM_JS)
fn = _fn(js, "settleVerbatimPlaceholders")
assert 'root.querySelectorAll(".verbatim-pending")' in fn
assert "buildVerbatimUnresolved(" in fn
assert "replaceWith(" in fn, "the pending node is REPLACED, never patched"
assert 'node.getAttribute("data-bor-locator")' in fn, (
"the locator the model named rides into the honest state"
)
unresolved = _fn(js, "buildVerbatimUnresolved")
assert 'root.dataset.verbatimState = (opts && opts.kind) || "unverified"' in unresolved, (
"the settle kind is recorded for the next reader"
)
assert "state.textContent = verbatimUnresolvedCopy()" in unresolved
# ---------------------------------------------------------------------------
# CSP + the house XSS rule (the new client, and the new code in the pages)
# ---------------------------------------------------------------------------
def test_no_inline_handlers_and_no_html_anywhere_in_the_client() -> None:
"""CSP is ``default-src 'self'``: no ``onclick=``/``oncopy=`` attribute
strings, no inline ``<script>``, no HTML string, no ``innerHTML`` (not
even ``+=``) in ``verbatim.js`` — every node is ``createElement``, every
value ``textContent``/``title``/a built ``href``, every handler
``addEventListener``."""
code = _strip_comments(_text(VERBATIM_JS))
for marker in (
"onclick=",
"oncopy=",
"onerror=",
"onload=",
".innerHTML",
".outerHTML",
"insertAdjacentHTML(",
"document.write(",
"eval(",
"<script",
):
assert marker not in code, f"verbatim.js must stay HTML/handler-free: {marker}"
assert "document.createElement(" in code
assert code.count("document.createElement(") > 15, (
"the panel is built node by node (the house rule)"
)
assert "addEventListener(" in code
def test_the_pages_new_code_adds_no_inline_handler_or_inner_html() -> None:
"""The task-07 additions to ``app.js`` / ``shared.js`` obey the same rule:
no ``innerHTML +=`` anywhere, and the only ``onclick=``-shaped text in the
two files stays inside their existing comments (none)."""
for path in (APP_JS, SHARED_JS):
js = _text(path)
assert "innerHTML += " not in js, f"{path.name}: no innerHTML concatenation"
assert "onclick=" not in js
assert "oncopy=" not in js
# ---------------------------------------------------------------------------
# app.js: the live turn, the save point, the restore, the terminal paths
# ---------------------------------------------------------------------------
def _done_branch(js: str) -> str:
start = js.index('ev.type === "done"')
end = js.index('ev.type === "tool_result"', start)
return js[start:end]
def test_the_done_handler_hydrates_before_the_source_clusters() -> None:
"""The order IS the layout: the answer image, then the hydrated panels,
then the blocks the answer never located, then the Copy buttons on the
settled code blocks, then the source clusters (phase-124/130 DOM order —
the panels sit below the answer text and above the chips)."""
branch = _done_branch(_text(APP_JS))
for call in (
"appendAnswerImage(wrap, ev.answer_image)",
"hydrateVerbatim(",
"appendUnlocatedVerbatim(wrap, ev.answer_text || [], VERBATIM_LINK_OPTS)",
"enhanceAnswerCodeBlocks(wrap)",
"appendSources(wrap, ev.sources)",
):
assert call in branch, f"the done handler must call {call}"
order = [
branch.index("appendAnswerImage("),
branch.index("hydrateVerbatim("),
branch.index("appendUnlocatedVerbatim("),
branch.index("enhanceAnswerCodeBlocks("),
branch.index("appendSources("),
]
assert order == sorted(order), "image → panels → unlocated → copy → sources"
assert "VERBATIM_LINK_OPTS" in branch[branch.index("hydrateVerbatim(") :][:200]
def test_the_answer_renderers_opt_in_and_nothing_else_does() -> None:
"""The per-delta write and the ``done``-time re-render both pass
``{ verbatim: true }`` (the third site is the restore renderer, pinned in
``test_the_restore_path_rehydrates_from_the_record``); the user bubble of
the same turn does NOT — a question is not a locator."""
js = _text(APP_JS)
branch = _done_branch(js)
assert "renderMarkdown(acc, { verbatim: true })" in js
assert "renderMarkdown(finalText, { verbatim: true })" in branch
assert 'addMessage(\n "user",\n renderMarkdown(text),' in js, (
"the sent question stays one-argument"
)
def test_the_save_point_persists_the_blocks_additively() -> None:
"""``answer_text: ev.answer_text || undefined`` on the ``done`` save point
— ``undefined`` drops the key from the JSON (the ``answer_image``
precedent), so a pre-phase record stays byte-identical and a
``answer_text=False`` turn stores nothing new."""
branch = _done_branch(_text(APP_JS))
assert "answer_text: ev.answer_text || undefined," in branch
turn = _fn(_text(APP_JS), "rememberBrainTurn")
assert "...meta" in turn, (
"the meta spread carries the key — rememberBrainTurn itself is untouched"
)
def test_the_restore_path_rehydrates_from_the_record() -> None:
"""Reload parity: the restore renderer opts in, then hydrates from
``m.answer_text`` and appends the unlocated blocks in the same band, then
settles the code blocks — the SAME builders as the live turn. A record
with no ``answer_text`` renders its fences as the unresolved state (the
record is what it is: never resurrect model bytes)."""
js = _text(APP_JS)
body = _fn(js, "renderStoredMessage")
assert 'addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)' in body
assert (
'hydrateVerbatim(wrap.querySelector(".bubble"), m.answer_text || [], VERBATIM_LINK_OPTS)'
in body
)
assert "appendUnlocatedVerbatim(wrap, m.answer_text || [], VERBATIM_LINK_OPTS)" in body
assert "enhanceAnswerCodeBlocks(wrap)" in body
order = [
body.index("appendAnswerImage(wrap, m.answer_image)"),
body.index("hydrateVerbatim("),
body.index("appendUnlocatedVerbatim("),
body.index("enhanceAnswerCodeBlocks("),
body.index("appendSources(wrap, m.sources)"),
]
assert order == sorted(order)
def test_every_terminal_path_settles_the_placeholders() -> None:
"""Nothing sits at \u201cRetrieving the exact text\u2026\u201d after a terminal
frame: the ``error`` frame handler, the 300 s guard, that guard's own
abort surface, the user-stop branch and the failed-turn funnel each call
``settleVerbatimPlaceholders(…, "unverified")`` — five sites, no more."""
js = _text(APP_JS)
assert js.count("settleVerbatimPlaceholders(") == 5, (
"the five terminal sites: error frame, timeout, aborted catch, stop "
"catch, failed-turn funnel"
)
assert js.count('"unverified"') >= 5, "every settle names the honest kind"
funnel = _fn(js, "finalizeFailedTurn")
assert 'settleVerbatimPlaceholders(wrap.querySelector(".bubble"), "unverified");' in funnel
assert funnel.index("closeAllRuns(wrap)") < funnel.index("settleVerbatimPlaceholders(")
stop = js[js.index("stoppedByUser || err?.name ===") :]
assert "settleVerbatimPlaceholders(" in stop[:800], "the Stop action settles"
frame = js[js.index('ev.type === "error"') :]
assert "settleVerbatimPlaceholders(" in frame[:700], "the error frame settles"
def test_the_chat_panel_links_back_to_the_chat_page() -> None:
"""The chat page's panels navigate EXACTLY like a source chip: the same
``documentUrl(…, "/")`` href (back → the chat, phase 153) and the same
left-click → same-page modal (phase 26), handed to the client as ONE
options object."""
js = _text(APP_JS)
opts = (
_fn(js, "VERBATIM_LINK_OPTS")
if "function VERBATIM_LINK_OPTS" in js
else js[
js.index("const VERBATIM_LINK_OPTS") : js.index(
"};", js.index("const VERBATIM_LINK_OPTS")
)
]
)
assert "documentHref:" in opts and 'documentUrl(source, path, "/")' in opts
assert "onOpen:" in opts and 'openDocumentModal(source, path, opener, "/")' in opts
# every app.js call site passes it
assert _text(APP_JS).count("VERBATIM_LINK_OPTS") >= 5
def test_the_share_path_delegates_to_the_one_clipboard_helper() -> None:
"""Task 07 consolidates the clipboard path: ``app.js``'s owner-locked
share copy delegates to ``copyTextWithFallback`` with the same host and
the same title, and the page no longer carries its own selection
helper."""
js = _text(APP_JS)
share = _fn(js, "copyShareLinkWithFallback")
assert "copyTextWithFallback(" in share
assert "composer," in share
assert '"Share link — click, then copy (Ctrl/Cmd+C)"' in share
assert "function selectAllInField" not in js, (
"the selection helper moved with the clipboard path — one implementation"
)
verbatim = _text(VERBATIM_JS)
assert "function selectAllInFallbackField" in verbatim
# ---------------------------------------------------------------------------
# shared.js parity
# ---------------------------------------------------------------------------
def test_shared_page_reaches_the_same_globals_with_a_plain_link() -> None:
"""Parity is a hard requirement: the shared renderer calls the SAME three
globals in the SAME band and order as the chat page's restore, and passes
a plain ``/document.html?source=&path=`` href with NO modal (that page has
none, and the documents API is admin-gated)."""
js = _text(SHARED_JS)
body = _fn(js, "renderSharedMessage")
assert "hydrateVerbatim(" in body
assert "appendUnlocatedVerbatim(wrap, m.answer_text || [], SHARED_VERBATIM_OPTS)" in body
assert "enhanceAnswerCodeBlocks(wrap)" in body
order = [
body.index("addAnswerImage(wrap, m.answer_image)"),
body.index("hydrateVerbatim("),
body.index("appendUnlocatedVerbatim("),
body.index("addSources(wrap, m.sources)"),
]
assert order == sorted(order)
opts = js[
js.index("const SHARED_VERBATIM_OPTS") : js.index(
"};", js.index("const SHARED_VERBATIM_OPTS")
)
]
assert "/document.html?source=" in opts and "encodeURIComponent(" in opts
assert "onOpen" not in opts, "no modal on the shared page"
assert "openDocumentModal" not in js
assert "document-modal" not in js
# ---------------------------------------------------------------------------
# styles.css: the states the JS adds stay inside the phase-182 block
# ---------------------------------------------------------------------------
def _css_block() -> str:
css = _text(STYLES_CSS)
start = css.index(BLOCK_BEGIN)
end = css.index(BLOCK_END, start)
return css[start : end + len(BLOCK_END)]
@pytest.mark.parametrize(
"selector", [".verbatim-body", ".verbatim-text", ".verbatim-open", ".verbatim-more"]
)
def test_the_task_07_dom_selectors_are_declared_in_the_phase_182_block(
selector: str,
) -> None:
"""The DOM the panel builder really produces is styled INSIDE the labelled
region (so task 06's palette-only and contiguity pins keep holding): the
body is the flex row the gutter's ``flex: 0 0 auto`` was written for, and
the viewer link + the truncation line are themed, not unstyled."""
block = _css_block()
rules = re.findall(r"(?m)^" + re.escape(selector) + r" \{([^}]*)\}", block)
assert rules, f"{selector} must be declared inside the phase-182 block"
# task 06's `_body` idiom: task 07 extends a rule with its own declaration
# line (a second block for the same selector), so every body counts.
body = " ".join(rules)
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", body), "no hex colour"
for literal in ("rgb(", "rgba(", "hsl(", "hsla("):
assert literal not in body, f"{selector} must stay palette-only ({literal})"
assert not re.search(r":\s*(?:white|black|#fff|#000)\s*[;}]", body), (
f"{selector} must not name a colour"
)
if selector == ".verbatim-body":
assert "display: flex" in body, "the gutter + text row the sticky gutter needs"
if selector == ".verbatim-body":
assert "display: flex" in body, "the gutter + text row the sticky gutter needs"
if selector == ".verbatim-open":
assert "text-decoration: underline" in body, "a link is not colour-only"
assert "var(--ink-soft)" in body, "the link is themed, not unstyled"
if selector == ".verbatim-more":
assert "var(--ink-soft)" in body and "var(--line)" in body
def test_the_viewer_link_reaches_the_mobile_touch_floor() -> None:
"""PLAN §7's 44px tap target applies to the panel's link too — it lives in
the ``max-width: 640px`` block beside the two Copy controls."""
css = _text(STYLES_CSS)
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "the 640px block must exist"
hit = re.search(r"\.verbatim-open \{([^}]*)\}", mobile.group(1))
assert hit and "min-height: 44px" in hit.group(1)
# ---------------------------------------------------------------------------
# node: the panel's pure logic, run against the real file
# ---------------------------------------------------------------------------
_HARNESS = """
const fs = require("fs");
const src = fs.readFileSync(process.argv[2], "utf8");
const api = new Function(
src +
"\\nreturn { verbatimCaptionText, verbatimGutter, verbatimPanelLabel," +
" verbatimIdentity, verbatimSplitLocator, verbatimDocumentHref," +
" isVerbatimBlock, verbatimBlocks, matchVerbatimBlock, verbatimUnresolvedCopy," +
" verbatimBadgeTitle };"
)();
const cases = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
const out = {};
for (const [name, body] of Object.entries(cases)) {
try {
// `with` so the case bodies read the panel API as plain names (the file
// itself is sloppy-mode classic-script code, and the harness is its
// own scope — no strict-mode conflict).
const fn = new Function("api", "with (api) { return (" + body + "); }");
out[name] = { ok: true, value: fn(api) };
} catch (err) {
out[name] = { ok: false, error: String(err && err.message ? err.message : err) };
}
}
process.stdout.write(JSON.stringify(out));
"""
@pytest.fixture(scope="module")
def tmp_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
return tmp_path_factory.mktemp("verbatim")
def _node(cases: dict[str, str], tmp_dir: Path) -> dict[str, object]:
"""Run *cases* (JS bodies receiving the panel API) under node against the
real ``verbatim.js`` — the pure half needs no DOM."""
script = tmp_dir / "harness.cjs"
script.write_text(_HARNESS, encoding="utf-8")
case_file = tmp_dir / "cases.json"
case_file.write_text(
# the case bodies are plain JS EXPRESSIONS evaluated with the panel
# API in scope (the harness wraps them, see _HARNESS)
json.dumps(dict(cases)),
encoding="utf-8",
)
proc = subprocess.run(
["node", str(script), str(VERBATIM_JS), str(case_file)],
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_caption_prints_the_servers_extent(tmp_dir: Path) -> None:
"""The caption is the SERVER's resolved extent (LOCKED A2 made visible):
a slice reads ``path · lines 12\u201340 of 210``, a whole document reads
``path · all 210 lines``, a CAPPED block is never labelled "all" even when
it starts at line 1, and a garbage count degrades to the bare path rather
than printing a lie."""
out = _node(
{
"slice": """verbatimCaptionText({ source: "homelab", path: "a.md",
start_line: 12, end_line: 40, total_lines: 210 })""",
"whole": """verbatimCaptionText({ source: "homelab", path: "a.md",
start_line: 1, end_line: 210, total_lines: 210 })""",
"capped": """verbatimCaptionText({ source: "homelab", path: "a.md",
start_line: 1, end_line: 30, total_lines: 210, truncated: true })""",
"no_total": """verbatimCaptionText({ source: "homelab", path: "a.md",
start_line: 3, end_line: 4 })""",
"garbage": """verbatimCaptionText({ source: "homelab", path: "a.md",
start_line: "x", end_line: null })""",
"label": """verbatimPanelLabel({ source: "homelab", path: "a.md",
start_line: 12, end_line: 40 })""",
"label_whole": """verbatimPanelLabel({ source: "homelab", path: "a.md",
start_line: 1, end_line: 1 })""",
"href": """verbatimDocumentHref("src a", "dir/x y.md")""",
},
tmp_dir,
)
assert out["slice"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 12\u201340 of 210"}
assert out["whole"] == {"ok": True, "value": "homelab/a.md \u00b7 all 210 lines"}
assert out["capped"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 1\u201330 of 210"}
assert out["no_total"] == {"ok": True, "value": "homelab/a.md \u00b7 lines 3\u20134"}
assert out["garbage"] == {"ok": True, "value": "homelab/a.md"}
assert out["label"] == {
"ok": True,
"value": "Verbatim text from homelab/a.md, lines 12\u201340",
}
assert out["label_whole"] == {
"ok": True,
"value": "Verbatim text from homelab/a.md, line 1",
}, "one line reads `line N`, never `lines 1–1`"
# SEC: the href is built from ENCODED halves (the `documentUrl` parity —
# the whole path is one query value, slashes included, and FastAPI decodes
# it back), and it is always a same-origin relative URL.
assert out["href"] == {
"ok": True,
"value": "/document.html?source=src%20a&path=dir%2Fx%20y.md",
}
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_gutter_numbers_the_servers_lines_and_not_the_marker(tmp_dir: Path) -> None:
"""One gutter row per line of the server text (so the rows align with the
``<pre>``), numbered from the server's ``start_line``. The truncation
marker the cap appended is a real line of the panel but NOT a line of the
file, so it gets a blank cell (an empty cell keeps the rows aligned \u2014 the
gutter never claims a coordinate the file does not have)."""
out = _node(
{
"range": """verbatimGutter({ text: "one\\ntwo\\nthree", start_line: 12,
end_line: 14 }).map(r => r.number).join("|")""",
"truncated": """verbatimGutter({ text: "one\\ntwo\\n\\u2026truncated\\u2026",
start_line: 1, end_line: 2, truncated: true })
.map(r => JSON.stringify(r.number)).join("|")""",
"unnumbered": """verbatimGutter({ text: "a\\nb" }).map(r => r.number).join("|")""",
},
tmp_dir,
)
assert out["range"] == {"ok": True, "value": "12|13|14"}
assert out["truncated"] == {"ok": True, "value": '"1"|"2"|" "'}
assert out["unnumbered"] == {"ok": True, "value": " | "}
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_match_rule_exact_then_unique_path_then_none(tmp_dir: Path) -> None:
"""The ONE hydration rule: the canonical locator first; else exactly ONE
block for the same ``source/path`` (the model's range was wrong \u2014 the panel
shows the server's extent); else null, INCLUDING the ambiguous case (two
blocks, same file, different ranges \u2014 never guess)."""
out = _node(
{
"exact": """(() => { const b = [
{ source: "h", path: "a.md", locator: "h/a.md#12-40" },
{ source: "h", path: "a.md", locator: "h/a.md#1-5" }];
return matchVerbatimBlock(b, "h/a.md#1-5").locator; })()""",
"path_only": """(() => { const b = [{ source: "h", path: "a.md",
locator: "h/a.md#1-9" }];
return matchVerbatimBlock(b, "h/a.md#99-120").locator; })()""",
"whole_doc_path_only": """(() => { const b = [{ source: "h", path: "a.md",
locator: "h/a.md" }];
return matchVerbatimBlock(b, "h/a.md").locator; })()""",
"ambiguous": """(() => { const b = [
{ source: "h", path: "a.md", locator: "h/a.md#1-9" },
{ source: "h", path: "a.md", locator: "h/a.md#20-29" }];
return matchVerbatimBlock(b, "h/a.md#7-8"); })()""",
"none": """matchVerbatimBlock([], "h/a.md#1-2")""",
"other_path": """matchVerbatimBlock([{ source: "h", path: "b.md",
locator: "h/b.md" }], "h/a.md")""",
"split": """JSON.stringify(verbatimSplitLocator("h/a.md#12-40"))""",
"split_no_range": """JSON.stringify(verbatimSplitLocator("h/a.md"))""",
},
tmp_dir,
)
assert out["exact"] == {"ok": True, "value": "h/a.md#1-5"}
assert out["path_only"] == {"ok": True, "value": "h/a.md#1-9"}
assert out["whole_doc_path_only"] == {"ok": True, "value": "h/a.md"}
assert out["ambiguous"] == {"ok": True, "value": None}
assert out["none"] == {"ok": True, "value": None}
assert out["other_path"] == {"ok": True, "value": None}
assert out["split"] == {
"ok": True,
"value": '{"identity":"h/a.md","hasRange":true}',
}
assert out["split_no_range"] == {
"ok": True,
"value": '{"identity":"h/a.md","hasRange":false}',
}
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_malformed_blocks_are_dropped_and_never_throw(tmp_dir: Path) -> None:
"""A malformed / missing ``answer_text`` is a NON-event: every odd shape
(null, a bare object, a block without text, a block without a path,
numbers-as-strings) is filtered out instead of throwing \u2014 hydration stays
honest for the placeholders instead of painting garbage."""
out = _node(
{
"undefined_blocks": """verbatimBlocks(undefined).length""",
"not_an_array": """verbatimBlocks({ length: 1, 0: { source: "h", path: "a.md",
text: "x" } }).length""",
"mixed": """verbatimBlocks([null, 7, "s", {}, { source: "", path: "a.md", text: "x" },
{ source: "h", path: "", text: "x" },
{ source: "h", path: "a.md" },
{ source: "h", path: "a.md", text: "" }]).length""",
"identity_with_empty_text": """isVerbatimBlock({ source: "h",
path: "a.md", text: "" })""",
"identity": """verbatimIdentity("homelab", "runbooks/a.md")""",
"unresolved_copy": """verbatimUnresolvedCopy()""",
"badge_title": """verbatimBadgeTitle()""",
},
tmp_dir,
)
assert out["undefined_blocks"] == {"ok": True, "value": 0}
assert out["not_an_array"] == {"ok": True, "value": 0}
# only the last entry survives: the others miss source/path/text.
assert out["mixed"] == {"ok": True, "value": 1}
assert out["identity_with_empty_text"] == {"ok": True, "value": True}
assert out["identity"] == {"ok": True, "value": "homelab/runbooks/a.md"}
assert out["unresolved_copy"] == {"ok": True, "value": UNRESOLVED_COPY}
assert out["badge_title"] == {"ok": True, "value": BADGE_TITLE}
+465
View File
@@ -0,0 +1,465 @@
"""Unit: the phase-182 verbatim-panel stylesheet contract (task 06).
The panel DOM is built by ``verbatim.js`` (task 07) and its streaming
placeholder by ``markdown.js`` (task 06); this module pins the CSS contract
that makes the phase readable, in the ``test_frontend_cluster_css.py`` /
``test_markdown_inline_links_images.py`` house pattern (no browser — the
browser-level evidence is the phase's Playwright suite, task 08):
* the phase-182 block EXISTS as one clearly-labelled region of
``frontend/assets/styles.css`` (BEGIN/END markers) and every selector the
contract names is styled there — ``.verbatim``, ``.verbatim-head``,
``.verbatim-caption``, ``.verbatim-badge``, ``.verbatim-body``,
``.verbatim-lines``, ``.verbatim-pending``, ``.verbatim-state``,
``.verbatim-unresolved``, ``.code-block``, ``.code-block-copy``,
``.verbatim-copy``;
* the panel is a BOUNDED scroll panel (PLAN §7.1): ``max-height`` in the
``min(…, 70vh)`` form + ``overflow: auto``, with the head row STICKY
(``position: sticky; top: 0``) on an opaque background so the caption
never scrolls away and rows never show through it;
* the code surface never wraps (phase 116 locked A1, the phase-165 prose/code
split): ``white-space: pre`` + ``overflow-wrap: normal`` + ``tab-size: 2``,
and the line-number gutter is ``user-select: none`` so a Copy/select never
drags numbers into the clipboard;
* text + color, never color alone (WCAG 1.4.1): the badge is a TEXT badge
with ``text-transform: none``, the unresolved state hides the badge, and
the ``.copied`` state exists beside the text swap task 07 performs;
* the whole block is PALETTE-VARIABLES ONLY (the phase-92 zero-literal /
monochrome invariant): no hex/rgb/hsl literal anywhere, and every
``var()`` is a declared ``:root`` token — so a light theme keeps this code
surface readable and no new hue enters the system;
* the ≤640px block lifts the Copy controls to the 44px touch floor, and
nothing in the block animates (so ``prefers-reduced-motion`` has nothing
to still — the rule is written as a guard so a future animation cannot be
added without its still).
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
STYLES_CSS = Path(__file__).resolve().parents[2] / "frontend" / "assets" / "styles.css"
#: The phase-182 region markers (the block is one contiguous, labelled
#: region, so "no literals in the phase-182 block" is a parseable claim).
BEGIN = "/* ---------- Verbatim panels (phase 182, task 06) — BEGIN ----------"
END = "/* ---------- Verbatim panels (phase 182, task 06) — END ---------- */"
#: Every selector the task's contract names.
PANEL_SELECTORS = (
".verbatim",
".verbatim-head",
".verbatim-caption",
".verbatim-badge",
".verbatim-body",
".verbatim-lines",
".verbatim-pending",
".verbatim-state",
".verbatim-unresolved",
".code-block",
".code-block-copy",
".verbatim-copy",
)
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _phase182_block(css: str) -> str:
"""The phase-182 region, markers included (comments intact — the block
carries the contract in prose too)."""
start = css.find(BEGIN)
assert start != -1, "styles.css must carry the labelled phase-182 block (BEGIN marker)"
end = css.find(END, start)
assert end != -1, "the phase-182 block must be closed by its END marker"
return css[start : end + len(END)]
def _strip_comments(css: str) -> str:
return re.sub(r"/\*[\s\S]*?\*/", "", css)
def _rule_bodies(block: str, selector: str) -> list[str]:
"""The bodies of every rule whose selector list contains ``selector``
EXACTLY (the phase-180 parse pattern, comment-stripped)."""
bodies: list[str] = []
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", _strip_comments(block)):
selectors = [s.strip() for s in " ".join(match.group(1).split()).split(",")]
if selector in selectors:
bodies.append(match.group(2))
return bodies
def _body(block: str, selector: str) -> str:
bodies = _rule_bodies(block, selector)
assert bodies, f"styles.css must style {selector}"
return " ".join(bodies)
def _root_palette(css: str) -> set[str]:
"""Every variable name the ``:root`` block declares."""
root = re.search(r":root\s*\{", css)
assert root, "styles.css must define :root"
body = css[root.end() : css.find("}", root.end())]
palette = set(re.findall(r"(--[a-z0-9-]+)\s*:", body))
assert palette, "the :root palette must declare variables"
return palette
def _media_block(css: str, query: str) -> str:
"""The body of the ``@media (<query>)`` block — the house non-greedy
pattern (nested closing braces are indented, so the first column-0 ``}``
is the block's own end)."""
m = re.search("@media \\(" + re.escape(query) + r"\) \{([\s\S]*?)\n\}", css)
assert m, f"{query} media query missing"
return m.group(1)
_CSS = _css()
_BLOCK = _phase182_block(_CSS)
_ROOT = _root_palette(_CSS)
# ---------------------------------------------------------------------------
# the block and its selectors
# ---------------------------------------------------------------------------
def test_phase_182_block_is_labelled_and_contains_every_selector() -> None:
"""The block is ONE clearly-labelled region carrying every selector of
the contract (task 06 item 5) — a rule that drifted outside the region
would escape the palette-only and reduced-motion pins below."""
for selector in PANEL_SELECTORS:
assert _rule_bodies(_BLOCK, selector), f"the phase-182 block must style {selector}"
# …and the block says what it is.
assert "phase 182" in _BLOCK
assert "VERBATIM" in _BLOCK, "the block documents the badge it styles"
def test_panel_is_a_themed_surface_card() -> None:
"""``.verbatim`` is a block on the theme's own canvas: the bubble's
rhythm (a vertical margin), a --line hairline, --radius-sm corners, the
--surface fill, ``min-width: 0`` (the long-line escape hatch every
scroller here carries) and ``overflow: hidden`` so the rounded corners
clip the scrolled body."""
body = _body(_BLOCK, ".verbatim")
for prop in (
"display: block",
"min-width: 0",
"background: var(--surface)",
"border: 1px solid var(--line)",
"border-radius: var(--radius-sm)",
"overflow: hidden",
):
assert prop in body, f".verbatim must carry {prop}"
assert re.search(r"margin:\s0\.[0-9]+rem 0;", body), (
".verbatim sits in the bubble's rhythm (a rem margin, not a px one)"
)
def test_head_row_is_sticky_with_an_opaque_background() -> None:
"""PLAN §7.1's bounded-panel rule: the head row STICKS to the top of the
scroll panel, so the caption (``source/path · lines 12–40 of 210``) is
still on screen in a 40-line slice. OPAQUE ``--surface`` background — a
translucent head would let the bytes read through it (the phase-156
``.table-wrap table thead th`` idiom)."""
body = _body(_BLOCK, ".verbatim-head")
assert "position: sticky" in body, "the head row must be sticky"
assert "top: 0" in body, "the sticky offset is the panel top"
assert "background: var(--surface)" in body, "the sticky head must be opaque"
assert "z-index: 1" in body, "the sticky head paints above the scrolled body"
assert "display: flex" in body, "the head is a row (caption, badge, copy)"
def test_caption_is_mono_soft_and_clipped_the_house_way() -> None:
"""``.verbatim-caption`` uses the phase-156 ``.cell-clip`` ellipsis
convention (``min-width: 0`` + ``overflow: hidden`` +
``text-overflow: ellipsis`` + ``white-space: nowrap``) so a long
``source/path`` clips to ``…`` instead of breaking the head row — the
FULL text stays reachable through the control's ``aria-label``/``title``
(the clip is visual only). ``--ink-soft`` on ``--surface`` is 8.2:1
(AA), and the mono family matches every other path in the app."""
body = _body(_BLOCK, ".verbatim-caption")
for prop in (
"min-width: 0",
"overflow: hidden",
"text-overflow: ellipsis",
"white-space: nowrap",
"font-family: var(--mono)",
"color: var(--ink-soft)",
):
assert prop in body, f".verbatim-caption must carry {prop}"
# the ellipsis convention must not rely on a fixed max-width here: the
# caption is the flexible item of a wrapping flex row.
assert "flex: 1" in body, "the caption takes the row's flexible space"
def test_badge_is_text_with_no_uppercase_surprise() -> None:
"""The badge is the WORD ``VERBATIM`` in the --ok family (10.6:1 on its
own fill) with ``text-transform: none`` — an uppercase-forced word is
spelled out letter-by-letter by some screen readers, and an icon-only
badge would fail WCAG 1.4.1 (the claim must be TEXT)."""
body = _body(_BLOCK, ".verbatim-badge")
assert "text-transform: none" in body
assert "color: var(--ok-ink)" in body, "the badge is the --ok family"
assert "background: var(--ok-bg)" in body
assert "content:" not in body, "the badge text is DOM text, never a CSS ::before"
def test_body_is_a_bounded_scroll_panel_of_code() -> None:
"""``.verbatim-body`` is the bounded scroll panel: ``max-height`` in the
``min(…, 70vh)`` form (PLAN §7.1 — the panel never eats the page),
``overflow: auto`` both axes, the mono family, ``white-space: pre`` and
``tab-size: 2`` (the file's own indentation survives), and
``overflow-wrap: normal`` — CODE scrolls horizontally and prose never
does (phase 116 locked A1, the phase-165 split), which also cancels the
``.bubble``'s inherited ``overflow-wrap: anywhere``. ``min-width: 0``
keeps the long-line escape hatch."""
body = _body(_BLOCK, ".verbatim-body")
assert re.search(r"max-height:\s*min\(\s*[0-9.]+rem,\s*70vh\s*\)", body), (
".verbatim-body must cap with max-height: min(<rem>, 70vh)"
)
for prop in (
"overflow: auto",
"min-width: 0",
"font-family: var(--mono)",
"white-space: pre",
"tab-size: 2",
"overflow-wrap: normal",
):
assert prop in body, f".verbatim-body must carry {prop}"
assert "white-space: pre-wrap" not in body, "code never wraps (locked A1)"
def test_line_numbers_are_not_selectable() -> None:
"""The gutter is ``user-select: none`` (with the ``-webkit-`` prefix) on
BOTH the container and its items, right-aligned in ``--ink-soft``: a
reader who selects the panel — or a Copy path that reads the selection —
never drags line numbers into the clipboard. ``list-style: none`` means
the digits are TEXT, so the rule behaves identically in every engine."""
container = _body(_BLOCK, ".verbatim-lines")
for prop in (
"user-select: none",
"-webkit-user-select: none",
"text-align: right",
"color: var(--ink-soft)",
"list-style: none",
):
assert prop in container, f".verbatim-lines must carry {prop}"
items = _body(_BLOCK, ".verbatim-lines li")
assert "user-select: none" in items, "the <li> line numbers must be unselectable too"
def test_pending_state_is_muted_and_static() -> None:
"""The streaming state (``.verbatim-pending`` + ``.verbatim-state``, the
markdown.js placeholder) is MUTED — ``--ink-soft`` on ``--surface``,
8.2:1 — and it never announces itself with motion: the pending line is a
``role="status"`` live region, so the announcement IS the feedback."""
pending = _body(_BLOCK, ".verbatim-pending")
state = _body(_BLOCK, ".verbatim-state")
assert "color: var(--ink-soft)" in state, "the pending line is muted (not an answer)"
assert "background: var(--surface)" in pending
assert "animation" not in pending and "transition" not in pending
assert "animation" not in state and "transition" not in state
def test_unresolved_state_carries_no_badge() -> None:
"""The honest failure state: the --err boundary + --err-bg canvas with
--ink-soft text (7.9:1, AA), and CSS-side enforcement that a ``VERBATIM``
badge NEVER appears on an unresolved panel whatever DOM reaches it — the
badge belongs only to a server-resolved block (locked A5)."""
body = _body(_BLOCK, ".verbatim-unresolved")
assert "var(--err" in body, "the unresolved state is the --err family"
assert "color: var(--ink-soft)" in body
hide = _body(_BLOCK, ".verbatim-unresolved .verbatim-badge")
assert "display: none" in hide, "an unresolved panel never badges"
def test_copy_controls_are_pills_with_a_ring_and_an_ellipsis_label() -> None:
"""``.code-block-copy`` / ``.verbatim-copy``: the house pill (mono text,
--line hairline, --surface fill, ink-soft label), the label is the clip
target (``text-overflow: ellipsis`` on its span, the control's
``aria-label`` naming the full path), the ``:focus-visible`` ring is the
app's 3px --brand ring, and the ``.copied`` rule exists to SUPPORT the
text swap (``Copy`` → ``Copied``) with colour — never colour alone."""
both = _body(_BLOCK, ".code-block-copy") + " " + _body(_BLOCK, ".verbatim-copy")
for prop in (
"border: 1px solid var(--line)",
"background: var(--surface)",
"color: var(--ink-soft)",
"font-family: var(--mono)",
"cursor: pointer",
"min-height:",
):
assert prop in both, f"the copy controls must carry {prop}"
label = _body(_BLOCK, ".code-block-copy > span")
assert "text-overflow: ellipsis" in label, "a clipped copy label shows the hint"
for selector in (".code-block-copy:focus-visible", ".verbatim-copy:focus-visible"):
ring = _body(_BLOCK, selector)
assert "outline: 3px solid var(--brand)" in ring, f"{selector} needs the house ring"
assert "outline-offset: 2px" in ring
for selector in (".code-block-copy.copied", ".verbatim-copy.copied"):
copied = _body(_BLOCK, selector)
assert "var(--ok" in copied, f"{selector} must use the --ok family as SUPPORT"
def test_plain_code_block_wrapper_is_not_a_verbatim_panel() -> None:
"""``.code-block`` is the plain wrapper task 07 puts around an ordinary
answer ``<pre>``: a Copy button belongs to it, the ``VERBATIM`` badge
does NOT — so the wrapper rule carries no --ok fill and no panel chrome
(locked A5: verbatim-and-badged OR plainly the model's, never a hybrid)."""
body = _body(_BLOCK, ".code-block")
assert "position: relative" in body
assert "min-width: 0" in body
assert "--ok" not in body, "an ordinary code block never wears the verbatim badge"
assert not _rule_bodies(_BLOCK, ".code-block .verbatim-badge"), (
"no badge rule may exist inside the plain wrapper"
)
# ---------------------------------------------------------------------------
# the palette-only invariant (phase 92 / the :root-drift guard)
# ---------------------------------------------------------------------------
def test_phase_182_block_uses_palette_variables_only() -> None:
"""NO colour literal anywhere in the phase-182 block: every colour comes
from a declared ``:root`` token (no hex, no ``rgb()``/``rgba()``, no
``hsl()``, no named colour). A literal would be a second source of truth
for the built-in palette (the ``test_theming.py`` drift guard parses the
built-ins out of this file) and a new hue would break the monochrome
invariant — this is also what keeps a code surface readable when the
owner switches the app to a light or a monochrome theme."""
code = _strip_comments(_BLOCK)
refs = re.findall(r"var\((--[a-z0-9-]+)\)", code)
assert refs, "the phase-182 block must reference palette vars"
for ref in set(refs):
assert ref in _ROOT, f"{ref} is not a :root palette var"
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", code), "no hex color literal in the block"
for fn in ("rgb(", "rgba(", "hsl(", "hsla("):
assert fn not in code, f"no {fn} color literal in the block"
# named colours are the other way a second source of truth sneaks in
for name in (": white", ": black", "white;", "black;", "white }", "black }"):
assert name not in code, f"no named colour in the block ({name.strip()})"
def test_phase_182_block_restates_no_global_focus_rule_wrongly() -> None:
"""The restatement of the global ``:focus-visible`` ring uses the SAME
values as the app-wide rule (3px --brand, 2px offset) — a different
ring here would be a second, drifting source of focus truth."""
global_rule = _css()[: _css().find("\n::selection")]
assert re.search(r":focus-visible \{\s*outline: 3px solid var\(--brand\);\s*"
r"outline-offset: 2px;", global_rule), (
"the app-wide :focus-visible rule is the baseline this block copies"
)
# ---------------------------------------------------------------------------
# mobile + reduced motion
# ---------------------------------------------------------------------------
def test_mobile_block_keeps_a_44px_control() -> None:
"""The ≤640px block lifts BOTH Copy controls to the 44px touch floor
(PLAN §7: the phone tap target) — the desktop pill is smaller, so the
rule must live in the media block, not only in the base rule."""
mobile = _media_block(_CSS, "max-width: 640px")
assert "Phase 182" in mobile, "the mobile block must name the phase it serves"
hit = re.search(r"\.code-block-copy, \.verbatim-copy \{([^}]*)\}", mobile)
assert hit, "the mobile block must raise both Copy controls together"
assert "min-height: 44px" in hit.group(1)
def test_nothing_in_the_block_animates_without_a_reduced_motion_still() -> None:
"""``prefers-reduced-motion`` is honored by anything that animates. The
phase-182 block animates NOTHING (the pending state is a live region, so
the text swap is the feedback), which this pins as a GUARD: if a future
edit adds an ``animation``/``transition``, the same selectors must appear
in a ``prefers-reduced-motion: reduce`` block that stills them."""
code = _strip_comments(_BLOCK)
animated: list[str] = []
for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", code):
body = match.group(2)
if re.search(r"(^|[;\s])(animation|transition)\s*:", body):
animated.append(" ".join(match.group(1).split()))
if not animated:
assert "prefers-reduced-motion" in _BLOCK, (
"a static block must say so, so the next reader does not add "
"a pulse 'to match the app'"
)
return
reduced = "\n".join(
m.group(0)
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\)[\s\S]*?\n\}", _CSS)
)
for selectors in animated:
for selector in selectors.split(","):
assert selector.strip() in reduced, (
f"{selector} animates but is never stilled under "
"prefers-reduced-motion"
)
def test_the_panel_never_repaints_the_existing_code_and_bubble_rules() -> None:
"""The neighbours are untouched: ``.bubble pre`` (the ordinary answer
code block) and ``.doc-raw`` (the viewer's scroller) keep their
declarations, so the phase adds a SURFACE and does not restyle every
code block in the app."""
css = _CSS
pre = re.search(r"\.bubble pre \{([^}]*)\}", css)
assert pre, ".bubble pre must still exist"
for prop in ("background: var(--surface)", "border: 1px solid var(--line)", "overflow-x: auto"):
assert prop in pre.group(1), f".bubble pre must keep {prop}"
raw = re.search(r"\.doc-raw \{([^}]*)\}", css)
assert raw, ".doc-raw must still exist"
assert "white-space: pre" in raw.group(1)
def test_block_is_one_contiguous_region_of_the_sheet() -> None:
"""The BEGIN/END markers wrap ONE region (a phase that appends a stray
``.verbatim-*`` rule elsewhere escapes every pin in this module)."""
css = _CSS
assert css.count(BEGIN) == 1 and css.count(END) == 1
assert css.index(BEGIN) < css.index(END)
between = css[css.index(BEGIN) : css.index(END)]
# every verbatim selector lives inside it
for selector in PANEL_SELECTORS:
assert re.search(r"(?m)^" + re.escape(selector) + r"[^\n]*\{", between), (
f"{selector} must be declared inside the labelled phase-182 region"
)
outside = _strip_comments(css[: css.index(BEGIN)] + css[css.index(END) :])
for selector in PANEL_SELECTORS:
assert not re.search(
r"(?m)^" + re.escape(selector) + r"[^\n]*\{", outside
), f"{selector} must not be restyled outside the phase-182 region"
def test_selector_names_match_the_dom_the_renderer_and_task_07_build() -> None:
"""The CSS class names are the contract between the renderer's pending
template (``markdown.js``) and the panel builder (``verbatim.js``, task
07): the pending template's classes must be exactly the ones styled here,
so the streaming placeholder is never an unstyled div."""
js = (STYLES_CSS.parent / "markdown.js").read_text(encoding="utf-8")
for cls in ("verbatim", "verbatim-pending", "verbatim-caption", "verbatim-state"):
assert cls in js, f"markdown.js must emit .{cls}"
assert _rule_bodies(_BLOCK, "." + cls), f"styles.css must style .{cls}"
assert "verbatim-unresolved" in _BLOCK, (
"the unresolved class is part of THIS contract (task 07 renders it)"
)
@pytest.mark.parametrize("pair", [("--ink", "--surface"), ("--ink-soft", "--surface")])
def test_block_pairs_the_documented_text_tokens(pair: tuple[str, str]) -> None:
"""The block pairs its text with the canvas the :root comment documents
(``--ink`` and ``--ink-soft`` on ``--surface``) rather than inventing a
third ink — the AA duty is inherited from the palette, not restated."""
code = _strip_comments(_BLOCK)
assert re.search(r"color: var\(" + re.escape(pair[0]) + r"\)", code), (
f"the block must use {pair[0]}"
)
assert re.search(r"background: var\(" + re.escape(pair[1]) + r"\)", code), (
f"the block must sit on {pair[1]}"
)
@@ -0,0 +1,636 @@
"""Unit: the shared renderer's opt-in ``bor`` locator pass (phase 182, task 06).
Browser behavior lands in the phase's Playwright suite (task 08); here the
established ``test_frontend_*`` convention pins the SOURCE contract of
``frontend/assets/markdown.js`` without a browser — the second, optional
argument and the single truthy guard, the pass ORDER (verbatim BEFORE
fences), the ``\\u0000VERBATIMn\\u0000`` placeholder + restore step, the exact
pending template (``verbatim-pending`` / ``data-bor-locator`` /
``verbatim-caption`` / ``role="status"`` / the pinned copy
``Retrieving the exact text…``), the local ``borLocator`` normalizer and the
separator/range grammar it accepts, and the fact that the ordinary-fence
``<pre><code>`` output string is UNCHANGED (task 06 item 4: task 07 wraps
``<pre>`` nodes at the DOM level instead, so the document viewer, the
thinking blocks and every other caller keep byte-identical HTML).
The node-driven behavioral half (skipped when node is unavailable, the
phase-44/138/180 idiom) proves the story shapes: the completion criterion —
``renderMarkdown("```bor homelab/a.md 12-40\\nGARBAGE\\n```", {verbatim:
true})`` yields the placeholder DOM with
``data-bor-locator="homelab/a.md#12-40"`` and NO ``GARBAGE`` anywhere in the
returned HTML (locked A7, the phase's core contract) — while the SAME call
without the second argument returns the pre-phase output byte-for-byte; a
bare `` ```bor `` (no path) survives to the fence pass untouched; an
unparseable locator is put back byte-for-byte (no placeholder, no badge, the
model's own body — neither improved nor worsened); a fence left OPEN at the
very end of the input shows the pending state; two fences restore in answer
order; a locator carrying markup stays inert escaped text; and a standalone
placeholder is not wrapped in a ``<p>``.
Parity half: ``borLocator`` is the documented CLIENT MIRROR of Python
``app.rag.verbatim.canonical_locator``. The phase's E2E (task 08) pins that
parity end to end (both sides must agree or hydration misses); this module
pins the GRAMMAR too, by running both implementations over the same table
and over a deterministic fuzz — the only way to catch a grammar drift
before there is a browser in the loop.
"""
from __future__ import annotations
import json
import random
import re
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
HAVE_NODE = shutil.which("node") is not None
#: The single truthy path of the phase-182 second argument.
VERBATIM_GUARD = "opts && opts.verbatim === true"
#: The verbatim fence pass — the pinned regex of task 06 item 2.
VERBATIM_FENCE_RE = r"/^```bor[ \t]+\S[^\n]*\n(?:[\s\S]*?^```[ \t]*$|(?![\s\S]))/gm"
#: The pre-existing fence pass, untouched (task 06 item 4).
ORDINARY_FENCE_RULE = (
'codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\\n$/, ""))}</code></pre>`);'
)
#: The untouched pre-phase paragraph guard (the phase-138/180 pin).
PARAGRAPH_GUARD = r"/^<(h\d|ul|ol|pre|li|blockquote)/"
#: The placeholder's block-element clause added by task 06.
PLACEHOLDER_GUARD = r'/^\u0000VERBATIM\d+\u0000$/'
#: The pinned pending copy (``…`` is ONE character, not three dots).
PENDING_COPY = "Retrieving the exact text\u2026"
#: The completion criterion's input.
CRITERION_INPUT = "```bor homelab/a.md 12-40\nGARBAGE\n```"
#: The pre-phase (verbatim-OFF) output of :data:`CRITERION_INPUT` — the
#: byte-pin of the "inert until opted in" contract. A `` ```bor path `` info
#: string never matched the ordinary fence rule (``/```(\w*)\n/`` needs the
#: language token ALONE on that line), so OFF it is escaped prose.
CRITERION_OFF_OUTPUT = "<p>```bor homelab/a.md 12-40<br>GARBAGE<br>```</p>"
#: The option task 07 will pass (never passed by any call site yet).
ON: dict[str, bool] = {"verbatim": True}
#: The inputs the node cases render, keyed by case name.
CASE_INPUT: dict[str, str] = {
"criterion": CRITERION_INPUT,
"bare_bor": "```bor\nfoo\n```",
"open_bare": "```bor\n",
"malformed": "```bor not-a-locator 12-40\nSECRET_BODY\n```",
"open_fence": "```bor homelab/a.md 12-40\n",
"two_fences": "```bor a/b 1-2\nBODY_ONE\n```\n\n```bor c/d\nBODY_TWO\n```",
"en_dash": "```bor a/b 12\u201340\nBODY\n```",
"l_prefix": "```bor a/b#L12-L40\nBODY\n```",
"xss": "```bor a\"/><script>alert(1)</script>/b.md 1-2\nBODY\n```",
"standalone": "prose\n\n```bor homelab/a.md\nBODY\n```\n\nafter",
"attached": "prose\n```bor homelab/a.md 1-2\nBODY\n```",
"nested": "```\ntext\n```bor a/b 1-2\nBODY\n```\n",
"no_bor": "# Title\n\nsome **text** and `code`\n\n| a |\n|---|\n| 1 |\n\n> quote\n",
}
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
def _render(cases: dict[str, dict[str, bool] | None]) -> dict[str, str]:
"""Run every case through the REAL renderer under node.
``None`` means the ONE-argument call (every pre-phase call site); a dict
is passed as the second argument as-is.
"""
parts = []
for name, opts in cases.items():
arg = "" if opts is None else f", {json.dumps(opts)}"
parts.append(f"out.{name} = renderMarkdown({json.dumps(CASE_INPUT[name])}{arg}); ")
driver = _text(MARKDOWN_JS) + "\nconst out = {}; " + "".join(parts)
driver += "console.log(JSON.stringify(out));"
results: dict[str, str] = json.loads(_run_node(driver))
return results
def _bor_locator_source() -> str:
js = _text(MARKDOWN_JS)
start = js.index("function borLocator(raw) {")
end = js.index("\nfunction renderMarkdown", start)
return js[start:end]
# ---------------------------------------------------------------------------
# the signature + the guard (task 06 item 1)
# ---------------------------------------------------------------------------
def test_render_markdown_takes_the_optional_opts_argument() -> None:
"""``renderMarkdown(md)`` became ``renderMarkdown(md, opts)`` — a SECOND,
OPTIONAL argument, so every existing call site (the document viewer, the
thinking blocks, the user bubbles) keeps working untouched — and the
ONLY truthy path is ``opts.verbatim === true`` (a strict ``===``, so
``{verbatim: 1}`` / ``{verbatim: "yes"}`` stay OFF)."""
js = _text(MARKDOWN_JS)
assert "function renderMarkdown(md, opts) {" in js, (
"renderMarkdown must gain the second, optional `opts` argument"
)
assert "function renderMarkdown(md) {" not in js, "the one-argument signature is gone"
body = js[js.index("function renderMarkdown(md, opts) {") :]
assert body.count(VERBATIM_GUARD) == 1, (
f"the pass must be guarded by exactly one `{VERBATIM_GUARD}` test"
)
assert "if (opts && " in js, "a missing opts object must be handled (no TypeError)"
def test_header_comment_records_the_phase_182_pass() -> None:
"""The header comment carries the three things the next reader needs: the
pass exists and is opt-in, WHY the model's body is discarded (locked A7),
and the byte-identical-when-off contract."""
js = _text(MARKDOWN_JS)
header = js[: js.find("function escapeHtml")]
flat = " ".join(
" ".join(line.strip().lstrip("*").strip() for line in header.splitlines()).split()
)
for needle, label in [
("Phase 182", "the phase reference"),
("opts && opts.verbatim === true", "the single truthy path"),
("BODY IS DISCARDED", "why the model's bytes are dropped"),
("locked A7", "the locked decision behind it"),
("canonical_locator", "the Python function this mirrors"),
("BYTE-IDENTICAL WHEN OFF", "the inert-when-off contract"),
("verbatim \u2192 fences \u2192 tables", "the new pass order"),
("STRICTER", "the deliberate asymmetry with the server's fences_in"),
("model's own body", "the malformed-locator degradation"),
]:
assert needle in flat, f"the header comment must record {label}: {needle!r}"
# ---------------------------------------------------------------------------
# the pass + the placeholder (task 06 item 2)
# ---------------------------------------------------------------------------
def test_verbatim_pass_runs_before_the_fence_pass() -> None:
"""Order is verbatim → fences → tables → escape+inline → blockquotes →
paragraphs → restore. The verbatim regex MUST sit ahead of the ordinary
fence replace, otherwise a locator fence is eaten as a code block first
and the placeholder never exists."""
js = _text(MARKDOWN_JS)
assert VERBATIM_FENCE_RE in js, f"the pinned fence regex is missing: {VERBATIM_FENCE_RE}"
fence_pass = js.find(ORDINARY_FENCE_RULE)
assert fence_pass != -1, "the ordinary fence pass must still be there"
assert js.find(VERBATIM_FENCE_RE) < fence_pass, (
"the verbatim pass must run BEFORE the fence pass"
)
# …and the rest of the pipeline keeps its order behind it.
for marker in ("const tables = []", "text = inline(escapeHtml(text));", r"split(/\n{2,}/)"):
assert fence_pass < js.find(marker), f"the pass order broke at {marker!r}"
def test_placeholder_is_created_and_restored() -> None:
"""A parsed locator becomes ``\\u0000VERBATIMn\\u0000`` and the FINAL restore
step (alongside TABLE/CODE) turns it back into the panel — the
placeholder survives escaping (NUL and digits are not escaped) and never
reaches the DOM itself."""
js = _text(MARKDOWN_JS)
assert "`\\u0000VERBATIM${verbatim.length - 1}\\u0000`" in js, (
"the placeholder must be the indexed VERBATIM marker"
)
restore = r".replace(/\u0000VERBATIM(\d+)\u0000/g"
assert restore in js, "the restore step must replace the VERBATIM placeholder"
table = js.find(r".replace(/\u0000TABLE(\d+)\u0000/g")
code = js.find(r".replace(/\u0000CODE(\d+)\u0000/g")
assert table < code < js.find(restore), "the restore order is TABLE → CODE → VERBATIM"
def test_pending_template_is_exact() -> None:
"""The restore emits EXACTLY the pinned pending template: the
``verbatim verbatim-pending`` panel, the canonical locator in
``data-bor-locator`` (task 07's hydration key), the clipped
``verbatim-caption``, and the ``role="status"`` state line carrying the
pinned copy — every interpolated value through ``escapeHtml``."""
js = _text(MARKDOWN_JS)
for marker in (
'<div class="verbatim verbatim-pending" data-bor-locator="${escapeHtml(canonical)}">',
'<span class="verbatim-caption">${escapeHtml(identity)}</span>',
f'<span class="verbatim-state" role="status">{PENDING_COPY}</span>',
):
assert marker in js, f"the pending template must contain {marker!r}"
assert 'data-bor-locator="${canonical}"' not in js, (
"the locator is model-written text landing in an ATTRIBUTE — it must "
"go through escapeHtml (the escape-first boundary)"
)
def test_malformed_locator_is_put_back_byte_for_byte() -> None:
"""``borLocator`` returning ``""`` returns the ORIGINAL match, so a
malformed locator degrades to whatever the fence pass does with this text
today (no placeholder, no badge, the model's own body) — this phase
neither improves nor worsens it."""
js = _text(MARKDOWN_JS)
assert 'if (canonical === "") return match;' in js, (
"an unparseable locator must be returned to the text unchanged"
)
def test_paragraph_guard_blocks_placeholder_and_untouched_guard_stays() -> None:
"""The step-3 guard keeps the pre-phase test verbatim (the phase-138/180
pin) and adds the placeholder clause, so a standalone panel restores as a
``<div>`` instead of landing inside a ``<p>``."""
js = _text(MARKDOWN_JS)
assert PARAGRAPH_GUARD in js, "the pre-phase paragraph guard must stay"
assert js.count(PARAGRAPH_GUARD) == 1, "there is exactly one paragraph guard"
assert PLACEHOLDER_GUARD in js, "the placeholder must be a block element too"
# ---------------------------------------------------------------------------
# borLocator — the client mirror (task 06 item 3)
# ---------------------------------------------------------------------------
def test_bor_locator_exists_and_handles_every_grammar_token() -> None:
"""``borLocator`` exists, is documented as the CLIENT MIRROR of
``app.rag.verbatim.canonical_locator``, and its body carries every
separator (`` ` ``, ``:``, ``#``, the ``L`` prefix) and range shape
(``12-40``, the en dash, ``L12-L40``, a bare ``12``) of the D7 grammar —
plus the refusals (non-numeric, ``< 1``, ``start > end``, trailing
junk)."""
body = _bor_locator_source()
for token, label in [
(r"[ \t:#]+", "the ` `/`:`/`#` separator class"),
(r"L?\d+", "the optional `L` line prefix"),
(r"[-\u2013]", "the hyphen AND the en dash range separator"),
("(?:", "the OPTIONAL range group (a bare `12` and no range at all)"),
("$/", "the end anchor (trailing junk is not a range)"),
('includes("/")', "the first-`/` identity requirement"),
("start < 1", "the `< 1` refusal"),
("start > end", "the `start > end` refusal"),
('replace(/^[Ll]+/, "")', "the `L` prefix strip on each bound"),
]:
assert token in body, f"borLocator must handle {label}: {token!r}"
def test_bor_locator_is_documented_as_the_python_mirror() -> None:
"""The docstring above ``borLocator`` names the Python function it
mirrors and states that parity is load-bearing (a divergence means
hydration misses), so nobody 'fixes' one side alone."""
js = _text(MARKDOWN_JS)
comment = js[: js.index("function borLocator(raw) {")]
flat = " ".join(
" ".join(line.strip().lstrip("/").strip() for line in comment.splitlines()).split()
)
for needle, label in [
("canonical_locator", "the Python function"),
("CLIENT MIRROR", "that this is the mirror, not the source"),
("PARITY IS LOAD-BEARING", "why both sides must agree"),
("#12-40", "the canonical range form"),
]:
assert needle in flat, f"the docstring must record {label}: {needle!r}"
def test_bor_locator_returns_canonical_digits_or_empty() -> None:
"""Source shape: the canonical output is ``identity#start-end`` (digits
only) and every refusal path returns ``""`` — never ``null``/``undefined``
(the renderer compares against ``""``)."""
body = _bor_locator_source()
assert "return `${identity}#${start}-${end}`;" in body
assert body.count('return "";') >= 6, "every refusal path must return the empty string"
assert "return null" not in body and "return undefined" not in body, (
'borLocator\'s not-a-locator value is "", so the renderer\'s '
'`if (canonical === "")` test cannot miss'
)
# ---------------------------------------------------------------------------
# ordinary fences are NOT wrapped in markdown (task 06 item 4)
# ---------------------------------------------------------------------------
def test_ordinary_fence_output_string_is_unchanged() -> None:
"""Task 06 item 4: do NOT wrap ordinary fences in markdown — task 07
wraps ``<pre>`` nodes at the DOM level, so the document viewer, the
thinking blocks and every other caller keep byte-identical HTML. The
fence rule and its ``<pre><code>`` output string are pinned verbatim."""
js = _text(MARKDOWN_JS)
assert r"/```(\w*)\n([\s\S]*?)```/g" in js, "the ordinary fence regex is unchanged"
assert ORDINARY_FENCE_RULE in js, "the ordinary fence output string is unchanged"
assert 'class="code-block"' not in js, (
"markdown.js must NOT emit the task-07 wrapper — that is a DOM-level wrap"
)
#: The answer renderers task 07 opted IN (the whole client switch — the
#: per-delta write, the ``done``-time re-render, the brain restore).
OPTED_IN_CALLS: tuple[str, ...] = (
"renderMarkdown(acc, { verbatim: true })",
"renderMarkdown(finalText, { verbatim: true })",
"renderMarkdown(m.text, { verbatim: true })",
)
def test_only_the_answer_renderers_opted_in() -> None:
"""Task 07 opted in exactly the THREE answer renderers (the per-delta
write, the ``done``-time re-render, the brain restore) — and the shared
page's one brain renderer. NOTHING else: the user-bubble renders, the
live thinking runs, the legacy thinking restore and the whole document
viewer stay one-argument calls, because a question and a scratchpad are
not locators (task 06's byte-identity contract for every other surface).
The count is the pin: a fourth ``verbatim: true`` in either file is a
surface the phase never claimed."""
app = _text(FRONTEND / "assets" / "app.js")
shared = _text(FRONTEND / "assets" / "shared.js")
for call in OPTED_IN_CALLS:
assert call in app, f"app.js must render the answer through {call}"
assert app.count("verbatim: true") == 3, (
"exactly the three answer renderers may opt in in app.js"
)
assert (
'addSharedMessage("brain", renderMarkdown(m.text, { verbatim: true }))' in shared
)
assert shared.count("verbatim: true") == 1, "the shared page opts in its one renderer"
# The neighbours stay one-argument, in both files.
assert 'addMessage("user", renderMarkdown(m.text), true)' in app
assert 'addSharedMessage("user", renderMarkdown(m.text))' in shared
for name in ("app.js", "shared.js"):
js = _text(FRONTEND / "assets" / name)
for site in re.findall(r"renderMarkdown\([^\n]*\)", js):
if "thinking" in site or "entry.text" in site:
assert "verbatim" not in site, f"{name}: {site} must stay one-argument"
# A document's own ```bor fence is the document's text — never a locator.
assert "verbatim: true" not in _text(FRONTEND / "assets" / "document.js")
# ---------------------------------------------------------------------------
# behavior (node): the story shapes + the completion criterion
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_locator_fence_yields_the_placeholder_and_drops_the_model_bytes() -> None:
"""THE completion criterion: the criterion input WITH ``{verbatim: true}``
yields the placeholder DOM with
``data-bor-locator="homelab/a.md#12-40"`` and NO ``GARBAGE`` anywhere in
the returned HTML (locked A7). Two fences restore in answer order, the en
dash and the ``L`` prefix canonicalize, and a standalone panel is not
wrapped in a ``<p>``."""
out = _render(
{
"criterion": ON,
"two_fences": ON,
"en_dash": ON,
"l_prefix": ON,
"standalone": ON,
}
)
html = out["criterion"]
assert 'class="verbatim verbatim-pending"' in html
assert 'data-bor-locator="homelab/a.md#12-40"' in html
assert ">homelab/a.md</span>" in html, "the caption carries the source/path"
assert f">{PENDING_COPY}</span>" in html
assert 'role="status"' in html
assert "GARBAGE" not in html, "the model's body must appear NOWHERE (locked A7)"
assert "\u0000" not in html, "no placeholder byte survives"
assert "<p>" not in html, "a standalone panel is a block, not a <p>"
two = out["two_fences"]
assert two.index("a/b#1-2") < two.index('data-bor-locator="c/d"'), (
"two fences keep answer order"
)
assert two.count("verbatim-pending") == 2
assert "BODY_ONE" not in two and "BODY_TWO" not in two
assert out["en_dash"].count('data-bor-locator="a/b#12-40"') == 1
assert "BODY" not in out["en_dash"]
assert out["l_prefix"].count('data-bor-locator="a/b#12-40"') == 1
assert "BODY" not in out["l_prefix"]
assert out["standalone"] == (
'<p>prose</p><div class="verbatim verbatim-pending" '
'data-bor-locator="homelab/a.md"><span class="verbatim-caption">homelab/a.md</span>'
f'<span class="verbatim-state" role="status">{PENDING_COPY}</span></div><p>after</p>'
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_off_paths_are_byte_identical_to_the_pre_phase_renderer() -> None:
"""Every call WITHOUT ``{verbatim: true}`` — no second argument, the
document viewer's one-argument call, and an explicit
``{verbatim: false}`` — returns the PRE-PHASE output byte-for-byte: the
criterion input is escaped prose (a `` ```bor path `` info string never
matched the ``/```(\\w*)\\n/`` fence rule), and a locator-free document
renders identically with and without the option."""
out = _render(
{
"criterion": None,
"malformed": None,
"two_fences": None,
"standalone": None,
"nested": None,
"no_bor": None,
}
)
assert out["criterion"] == CRITERION_OFF_OUTPUT, "the OFF path must not change a byte"
for name, html in out.items():
assert "verbatim" not in html, f"{name}: no placeholder markup with the option OFF"
# the option object exists but is false → the same bytes as no argument
on_false = _render({"criterion": {"verbatim": False}})
assert on_false["criterion"] == CRITERION_OFF_OUTPUT
truthy_but_strict = _render({"criterion": {}})
assert truthy_but_strict["criterion"] == CRITERION_OFF_OUTPUT, (
"the guard is `=== true`: an options object without the flag stays OFF"
)
# a locator-free document is untouched by the whole feature
assert _render({"no_bor": ON})["no_bor"] == out["no_bor"]
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_bare_and_malformed_fences_fall_through_to_the_ordinary_pass() -> None:
"""A bare `` ```bor `` (no path) is an ordinary code block and survives to
the fence pass UNTOUCHED even with the option on; a bare `` ```bor ``
with no body at all still paints its raw marker as prose; and an
unparseable locator degrades to exactly what the fence pass does with
this text today — the model's own body, no placeholder, no badge."""
out = _render({"bare_bor": ON, "open_bare": ON, "malformed": ON})
assert out["bare_bor"] == "<p><pre><code>foo</code></pre></p>", (
"a bare ```bor fence must render EXACTLY as it does pre-phase"
)
assert out["open_bare"] == "<p>```bor</p>"
assert out["malformed"] == "<p>```bor not-a-locator 12-40<br>SECRET_BODY<br>```</p>", (
"a malformed locator is put back byte-for-byte: no placeholder, "
"no badge, the model's own body"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_open_fence_at_the_end_shows_the_pending_state() -> None:
"""The second alternative: a fence left OPEN at the very end of the input
(the info line typed, the body not yet streamed) shows the pending
placeholder instead of raw backticks — the deliberate asymmetry with the
server's stricter ``fences_in`` (the server only ever sees a finished
answer, the client sees a partial one)."""
out = _render({"open_fence": ON})
html = out["open_fence"]
assert 'data-bor-locator="homelab/a.md#12-40"' in html
assert "```" not in html, "no raw backticks while a locator fence is open"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_locator_markup_stays_inert_escaped_text() -> None:
"""Escape-first holds on the new surface: a locator carrying ``"><script>``
is HTML-escaped in BOTH the attribute and the caption, so it can never
become a live element (the renderer's whole reason for existing)."""
html = _render({"xss": ON})["xss"]
assert "<script>" not in html
assert "&lt;script&gt;" in html
assert '&quot;/&gt;&lt;script&gt;' in html
assert "#1-2" in html, "the markup still canonicalized to a locator"
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_a_bor_line_inside_an_ordinary_fence_agrees_with_the_server() -> None:
"""Documented limitation shared with ``fences_in`` (both sides scan for
the opening line, neither supports a nested `` ``` ``): a ``bor`` locator
line inside an ordinary fence is a LOCATOR on both sides, so hydration
still matches — it is never silently half-code, half-panel on one side
only."""
from app.rag.verbatim import fences_in
html = _render({"nested": ON})["nested"]
assert 'data-bor-locator="a/b#1-2"' in html
assert fences_in(CASE_INPUT["nested"]) == ["a/b 1-2"], (
"the server must see the SAME locator in the same text"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_fence_attached_to_prose_still_loses_its_body() -> None:
"""A locator fence with NO blank line above it still loses the model's
body (the browser closes the surrounding ``<p>`` when it parses the
``<div>``, the same fix-up an ordinary fenced ``<pre>`` already relies
on) — the body never paints either way."""
html = _render({"attached": ON})["attached"]
assert 'data-bor-locator="homelab/a.md#1-2"' in html
assert "BODY" not in html
# ---------------------------------------------------------------------------
# parity with the server's canonical_locator (task 06 item 3)
# ---------------------------------------------------------------------------
#: The D7 grammar table: input → the canonical string BOTH sides must
#: produce, or ``""`` for "not a locator at all".
PARITY_TABLE: dict[str, str] = {
"homelab/a.md": "homelab/a.md",
"homelab/a.md 12-40": "homelab/a.md#12-40",
"homelab/a.md:12-40": "homelab/a.md#12-40",
"homelab/a.md#12-40": "homelab/a.md#12-40",
"homelab/a.md#L12-L40": "homelab/a.md#12-40",
"homelab/a.md 12\u201340": "homelab/a.md#12-40",
"homelab/a.md 12": "homelab/a.md#12-12",
"homelab/a.md 12-40": "homelab/a.md#12-40",
"homelab/a.md 12 - 40": "homelab/a.md#12-40",
" homelab/a.md ": "homelab/a.md",
"homelab/deep/dir/a.md#L3": "homelab/deep/dir/a.md#3-3",
"homelab/a.md#l3-l7": "homelab/a.md#3-7",
"homelab/a.md L7": "homelab/a.md#7-7",
# the en dash is a RANGE separator, never the identity/range separator,
# so this row is ONE whole-document identity whose path contains one:
"homelab/a.md\u201312-40": "homelab/a.md\u201312-40",
# refusals — not locators at all:
"a.md": "",
"": "",
" ": "",
"homelab/a.md 0-5": "",
"homelab/a.md 40-12": "",
"homelab/a.md x-y": "",
"homelab/a.md 12-40 extra": "",
"bor homelab/a.md": "",
"homelab/a.md#": "",
"homelab/a.md:": "",
"homelab/a.md-": "",
"homelab/my note.md 12": "",
}
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_bor_locator_matches_the_python_canonical_locator() -> None:
"""The mirror is exact: for every row of the grammar table the JS
``borLocator`` and the Python ``canonical_locator`` agree — and the table
itself is checked against the D7 spec, so a SIMULTANEOUS drift on both
sides is caught too. A divergence here means hydration misses: the panel
would sit unresolved forever."""
from app.rag.verbatim import canonical_locator
script = (
_bor_locator_source()
+ "\nconst t = "
+ json.dumps(PARITY_TABLE)
+ ";\nconst out = {};\nfor (const k of Object.keys(t)) out[k] = borLocator(k);\n"
"console.log(JSON.stringify(out));\n"
)
got: dict[str, str] = json.loads(_run_node(script))
for raw, expected in PARITY_TABLE.items():
assert got[raw] == expected, f"borLocator({raw!r}) != {expected!r} (got {got[raw]!r})"
python = canonical_locator(raw) or ""
assert python == expected, (
f"canonical_locator({raw!r}) = {python!r} disagrees with the D7 "
f"table value {expected!r}"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_fuzz_locator_parity() -> None:
"""A deterministic fuzz over the grammar's alphabet: the two
implementations must agree on every string, including the shapes nobody
hand-wrote (dangling separators, multi-bound ranges, lone ``L``s)."""
from app.rag.verbatim import canonical_locator
rng = random.Random(182)
alphabet = [
"a", "b", "/", ".", ":", "#", " ", "L", "l", "-", "\u2013", "1", "2", "9", "x", "\t",
]
samples = [
"".join(rng.choice(alphabet) for _ in range(rng.randint(0, 9))) for _ in range(400)
]
script = (
_bor_locator_source()
+ "\nconst t = "
+ json.dumps(samples)
+ ";\nconst out = [];\nfor (const k of t) out.push(borLocator(k));\n"
"console.log(JSON.stringify(out));\n"
)
got: list[str] = json.loads(_run_node(script))
for raw, js_value in zip(samples, got, strict=True):
python = canonical_locator(raw) or ""
assert js_value == python, f"parity break on {raw!r}: js={js_value!r} py={python!r}"
# ---------------------------------------------------------------------------
# no regression on the neighboring passes (the phase's byte-identity mandate)
# ---------------------------------------------------------------------------
def test_adjacent_renderer_fixtures_stay_pinned_where_they_live() -> None:
"""The phase-44 table, phase-138 blockquote and phase-180 image fixtures
are the byte-identity evidence for the passes this task sits next to —
they live in their own suites, so this pins that those suites still pin
them (a deleted fixture here would silently drop the guarantee)."""
for name, needle in [
("test_markdown_tables.py", "md-table-wrap"),
("test_markdown_blockquotes.py", '<blockquote class="md-quote">'),
("test_markdown_inline_links_images.py", 'class="md-img"'),
]:
text = _text(Path(__file__).resolve().parent / name)
assert needle in text, f"{name} must still pin {needle!r}"
+33 -11
View File
@@ -123,8 +123,12 @@ def test_agent_tools_off_is_the_three_base_tools() -> None:
"""LOCKED A2: toggle off (the default) → exactly the three base
tools, element-for-element equal to ``AGENT_TOOLS`` — and a FRESH
list (not the constant), so a caller mutating the offer can never
mutate the constant."""
off = agent_tools(_settings())
mutate the constant. Phase 182 (task 03) pin update, deliberate:
the flag pin passes ``answer_text=False`` explicitly — with
``answer_text`` ON (the shipped default) the offer additionally
carries ``show_text`` (pinned in ``tests/unit/test_agent.py``); the
``answer_images`` gate itself is unchanged."""
off = agent_tools(_settings(answer_text=False))
assert [t["function"]["name"] for t in off] == ["ls", "read", "grep"]
assert off == AGENT_TOOLS # element-for-element
assert off is not AGENT_TOOLS # a fresh list, never the constant itself
@@ -136,8 +140,12 @@ def test_agent_tools_on_appends_show_image() -> None:
"""Toggle on → the three base tools (byte-identical) plus the 4th
tool, ``show_image`` — the offer list the grounded turn passes to
the model request (LOCKED A2: ``BOR_ANSWER_IMAGES`` gates ONLY the
offer; the base three are unchanged)."""
on = agent_tools(_settings(answer_images=True))
offer; the base three are unchanged). Phase 182 (task 03) pin
update, deliberate: ``answer_text=False`` isolates the
``answer_images`` gate — with ``answer_text`` ON (the shipped
default) ``show_text`` rides LAST (pinned in
``tests/unit/test_agent.py``)."""
on = agent_tools(_settings(answer_images=True, answer_text=False))
assert [t["function"]["name"] for t in on] == ["ls", "read", "grep", "show_image"]
assert on[:3] == AGENT_TOOLS # the base three byte-identical
assert on[3] == SHOW_IMAGE_TOOL
@@ -166,7 +174,11 @@ def test_run_agent_gates_the_show_image_offer() -> None:
"""The wiring pin (completion criteria): a grounded turn's offer
(the ``tools`` on the model request, ``max_rounds`` > 0) is the
three base tools when the toggle is off — element-for-element the
pre-phase-124 list — and gains ``show_image`` only when it is on."""
pre-phase-124 list — and gains ``show_image`` only when it is on.
Phase 182 (task 03) pin update, deliberate: both apps pass
``answer_text=False`` to isolate the ``answer_images`` gate (with
the shipped default ON, ``show_text`` additionally rides last —
pinned in ``tests/unit/test_agent.py``)."""
class _ScriptedLLM:
"""One canned content-only round; records the ``tools`` every
@@ -207,12 +219,12 @@ def test_run_agent_gates_the_show_image_offer() -> None:
pass
llm_off = _ScriptedLLM()
asyncio.run(_consume(llm_off, _settings()))
asyncio.run(_consume(llm_off, _settings(answer_text=False)))
assert len(llm_off.requests) == 1
assert llm_off.requests[0][1] == AGENT_TOOLS # off: element-for-element pre-124
llm_on = _ScriptedLLM()
asyncio.run(_consume(llm_on, _settings(answer_images=True)))
asyncio.run(_consume(llm_on, _settings(answer_images=True, answer_text=False)))
sent = llm_on.requests[0][1]
assert sent is not None # the grounded turn (max_rounds > 0) offers tools
assert [t["function"]["name"] for t in sent] == [
@@ -427,14 +439,24 @@ def test_plan_turn_grounded_carries_clause_exactly_when_toggle_on() -> None:
IDENTICAL to the pre-task build when ``answer_images`` is off (the
default — the pre-phase pins pass untouched) and gains the clause
(inside ``<tools>``) exactly when it is on."""
off = _plan3(0.9, _settings())
off_settings = _settings()
off = _plan3(0.9, off_settings)
assert off.deflected is False
assert off.system_prompt == build_high_prompt([_P3_DOC])
# Phase 182 (task 04): the reconstruction mirrors the OTHER display
# clause's flag too (BOR_ANSWER_TEXT, default ON) so this pin stays
# about answer_images alone — the show_text clause is pinned in
# tests/unit/test_prompts.py.
assert off.system_prompt == build_high_prompt(
[_P3_DOC], show_text=off_settings.answer_text
)
assert SHOW_IMAGE_CLAUSE not in off.system_prompt
on = _plan3(0.9, _settings(answer_images=True))
on_settings = _settings(answer_images=True)
on = _plan3(0.9, on_settings)
assert on.deflected is False
assert on.system_prompt == build_high_prompt([_P3_DOC], show_image=True)
assert on.system_prompt == build_high_prompt(
[_P3_DOC], show_image=True, show_text=on_settings.answer_text
)
assert SHOW_IMAGE_CLAUSE in on.system_prompt
assert on.system_prompt.index("<tools>") < on.system_prompt.index(SHOW_IMAGE_CLAUSE)
assert on.system_prompt.index(SHOW_IMAGE_CLAUSE) < on.system_prompt.index("</tools>")
+8 -3
View File
@@ -599,13 +599,18 @@ def test_meta_rows_append_to_msg_body_never_into_the_bubble() -> None:
def test_restore_renders_brain_text_through_the_pinned_call_shape() -> None:
"""CORNER: the restore. The phase-14 restore renders every brain
record through `addMessage("brain", renderMarkdown(m.text), true)`
— with `m.text === ""` the node-pinned `renderMarkdown("") === ""
record through `addMessage("brain", renderMarkdown(m.text, { verbatim:
true }), true)` (the option is phase 182's verbatim opt-in — it changes
which FENCES render, never whether the text does) — with `m.text === ""`
the node-pinned `renderMarkdown("") === ""`
` (pinned above) makes the bubble CHILD-LESS ⇒ `:empty` ⇒ hidden:
an empty persisted answer paints no box, through the SAME rule
(locked A3) — no restore-path branch was added."""
js = _js()
assert js.count('addMessage("brain", renderMarkdown(m.text), true)') == 1
assert (
js.count('addMessage("brain", renderMarkdown(m.text, { verbatim: true }), true)')
== 1
)
def test_shared_page_builds_the_identical_msg_structure_no_change_needed() -> None:
+50
View File
@@ -18,10 +18,13 @@ from app.rag.prompts import (
ORIGIN_RULE,
PERSONA,
SEARCH_DISCIPLINE_RULE,
SHOW_IMAGE_CLAUSE,
SHOW_TEXT_CLAUSE,
SUGGEST_INTRO,
TOOLS_SECTION,
_base,
build_deflect_prompt,
build_high_prompt,
)
@@ -323,3 +326,50 @@ def test_suggest_intro_byte_locked() -> None:
) in SUGGEST_INTRO
# The retired phase-118 final sentence is gone from the constant.
assert "Cite the document(s) you used, by path." not in SUGGEST_INTRO
# ---------- SHOW_TEXT_CLAUSE (phase 182, task 04: verbatim display) ----------
#: Anchors for ``SHOW_TEXT_CLAUSE`` — the ``show_text`` / ```bor``` locator
#: teaching appended INSIDE ``<tools>`` when ``BOR_ANSWER_TEXT`` is on
#: (phase 182, LOCKED A1–A3/A6). Pinned the way ``TOOLS_SECTION`` is
#: (sha256 + exact prefix + total length) because this clause IS the fix
#: for the retype bug the phase exists for: a silent wording drift here
#: re-opens it (the tool alone never teaches that the user copies from
#: what is DISPLAYED, nor the fence syntax).
SHOW_TEXT_CLAUSE_SHA256 = "e03a8153ca6cde61c9437f3b66b4fa07cd2a3f9f2e24e3bb8c37ca1d7c7e9e3b"
SHOW_TEXT_CLAUSE_LEN = 1179
SHOW_TEXT_CLAUSE_PREFIX = (
"`show_text` displays part of a document to the user EXACTLY as stored"
)
def test_show_text_clause_byte_locked() -> None:
"""The clause is LOCKED copy: sha256 + exact prefix + total length,
plus the two sentences the phase's contract depends on verbatim (the
copy-from-the-display rule and the server-owns-the-numbers rule)."""
assert len(SHOW_TEXT_CLAUSE) == SHOW_TEXT_CLAUSE_LEN
assert _sha256(SHOW_TEXT_CLAUSE) == SHOW_TEXT_CLAUSE_SHA256
assert SHOW_TEXT_CLAUSE.startswith(SHOW_TEXT_CLAUSE_PREFIX)
assert SHOW_TEXT_CLAUSE.endswith("For an image use `show_image` instead.")
assert "the user copies from what is displayed, not from your prose" in SHOW_TEXT_CLAUSE
assert "the numbers you display are the server's, never yours" in SHOW_TEXT_CLAUSE
def test_high_prompt_display_clause_composition_is_byte_locked() -> None:
"""One mechanism, four combinations (phase 182, task 04): neither
flag reproduces the untouched :data:`TOOLS_SECTION` tail,
``show_image`` alone reproduces the phase-124 bytes exactly,
``show_text`` alone appends only the text clause, and both append
the image clause, a blank line, the text clause, then the closing
tag — ``TOOLS_SECTION`` itself is never edited (its own sha pin
above holds)."""
off = build_high_prompt([])
assert off.endswith(TOOLS_SECTION)
head = off[: -len("</tools>")]
assert build_high_prompt([], show_image=True) == head + SHOW_IMAGE_CLAUSE + "\n</tools>"
assert build_high_prompt([], show_text=True) == head + SHOW_TEXT_CLAUSE + "\n</tools>"
assert (
build_high_prompt([], show_image=True, show_text=True)
== head + SHOW_IMAGE_CLAUSE + "\n\n" + SHOW_TEXT_CLAUSE + "\n</tools>"
)
+175
View File
@@ -30,6 +30,15 @@ discipline rules stay byte-identical): the teaching refusals in
:mod:`app.rag.agent` re-state the same contract; the ``<tools>``
marker keying (HIGH only) is unchanged.
And the phase-182 ``show_text`` clause (task 04, LOCKED A1–A3/A6): the
verbatim-display teaching appended INSIDE ``<tools>`` when
``BOR_ANSWER_TEXT`` is on — the user copies from what is DISPLAYED, never
from the model's retype, and the inline ```bor <source/path> [range]```
fence places a server-resolved block. The clause composes with the
phase-124 ``show_image`` clause through one mechanism, and every build
the flags do not select stays byte-identical (the constant's own sha pin
lives in :mod:`tests.unit.test_prompt_lock`).
And the phase-119 cite discipline (task 04, LOCKED A5): the intro's
final sentence ("cite the document(s) you used, by path", phase 118)
is REPLACED — the HIGH prompt carries the discipline sentence exactly
@@ -40,6 +49,7 @@ the LOW prompt and ``PERSONA`` / ``TOOLS_SECTION`` stay byte-identical
from __future__ import annotations
import inspect
import re
import uuid
from datetime import UTC, datetime
@@ -52,6 +62,7 @@ from app.rag.prompts import (
PERSONA,
SEARCH_DISCIPLINE_RULE,
SHOW_IMAGE_CLAUSE,
SHOW_TEXT_CLAUSE,
SUGGEST_INTRO,
TOOLS_SECTION,
_base,
@@ -1281,6 +1292,170 @@ def test_show_image_clause_is_last_for_every_high_shape() -> None:
assert SHOW_IMAGE_CLAUSE not in off # the toggle-off twin carries none of it
# ---------- phase 182 (task 04, LOCKED A1–A3/A6): the show_text clause ----------
#
# The clause is the fix for the retype bug: nothing in the pre-phase prompt
# ever said the user copies from what is DISPLAYED, so the model retyped
# files from context and every retype differed from the stored bytes by a
# character. Pinned three ways: the locked sentence order (the behaviour
# the E2E and the persona depend on), the byte-identity of every build the
# flag does NOT select, and the composition of the two display clauses
# through the one "section minus its final </tools>" mechanism.
#: The clause's sentence anchors, in the locked order (task 04's work
#: list): display contract → when to call → do-NOT-retype → the ```bor```
#: placement syntax → the numbers belong to the server → the failure rule
#: → the image pointer.
SHOW_TEXT_ANCHORS: tuple[str, ...] = (
"displays part of a document to the user EXACTLY as stored",
"the user copies from what is displayed, not from your prose",
"Call it whenever the user wants to SEE, COPY or RUN a file",
"a quadlet, a config, a script, a unit file",
"do NOT repeat the displayed lines in your answer",
"may differ from the file by a character, and the user cannot tell",
"write a locator line on its own: three backticks followed by `bor`",
"the combined `source/path`, and optionally the line range (` 12-40`)",
"Leave its body EMPTY — the app replaces it with the exact stored text",
"Never type the file's content inside a `bor` block",
"Never guess line numbers — use the numbers `grep` and `read` reported",
"OMIT the range and display the whole document",
"the numbers you display are the server's, never yours",
"If `show_text` refuses, say what you could not display",
"do not reconstruct it from memory",
"For an image use `show_image` instead",
)
def test_show_text_clause_carries_every_locked_rule_in_order() -> None:
"""Every sentence of the contract is present, in the locked order, and
the clause is a clause — one paragraph, no ``<tools>`` markup of its
own (the ``SHOW_IMAGE_CLAUSE`` precedent)."""
positions = [SHOW_TEXT_CLAUSE.index(anchor) for anchor in SHOW_TEXT_ANCHORS]
assert positions == sorted(positions), SHOW_TEXT_ANCHORS
assert "\n" not in SHOW_TEXT_CLAUSE
assert SHOW_TEXT_CLAUSE.startswith("`show_text`")
assert SHOW_TEXT_CLAUSE.endswith("instead.")
assert "<tools>" not in SHOW_TEXT_CLAUSE and "</tools>" not in SHOW_TEXT_CLAUSE
# The fence language is named as a token, never as a stray substring.
assert re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", SHOW_TEXT_CLAUSE)
def test_high_prompt_default_stays_byte_identical_without_show_text() -> None:
"""Completion pin: ``show_text`` defaults to ``False`` — the default
build and the explicit ``show_text=False`` are byte-identical to each
other and end with the untouched ``TOOLS_SECTION``, with no trace of
the tool or the fence language (the sha pins in
:mod:`tests.unit.test_prompt_lock` pass UNMODIFIED)."""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
plain = build_high_prompt([doc])
assert build_high_prompt([doc], show_text=False) == plain
assert plain.endswith(TOOLS_SECTION)
assert SHOW_TEXT_CLAUSE not in plain
assert "show_text" not in plain
assert not re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", plain)
empty = build_high_prompt([])
assert build_high_prompt([], show_text=False) == empty
assert empty.endswith(TOOLS_SECTION)
assert SHOW_TEXT_CLAUSE not in empty
def test_high_prompt_show_text_clause_lands_inside_tools_after_the_copy() -> None:
"""``show_text=True`` builds EXACTLY the toggle-off prompt with its
final ``</tools>`` replaced by the clause (on its own line, after the
existing three-tool copy) + the closing tag — the three-tool copy is
byte-identical, ``TOOLS_SECTION`` is unedited, and the clause is the
last thing the model reads."""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
off = build_high_prompt([doc])
on = build_high_prompt([doc], show_text=True)
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
clause_start = on.index(SHOW_TEXT_CLAUSE)
assert on[:clause_start] == off[: -len("</tools>")]
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
assert "Answer as soon as you have what you need." in on[:clause_start]
assert on.endswith(SHOW_TEXT_CLAUSE + "\n</tools>")
assert on.index("Answer as soon as you have what you need.") < clause_start
assert clause_start < on.index("</tools>")
assert on.count(SHOW_TEXT_CLAUSE) == 1
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
def test_high_prompt_display_clauses_compose_independently() -> None:
"""The two display flags are independent (LOCKED A1): ``show_image``
only reproduces the phase-124 bytes exactly, ``show_text`` only is the
three-tool copy plus the text clause, both puts the image clause
FIRST and the text clause last, and neither is byte-identical to
pre-phase — no combination leaks the other's clause."""
doc = _doc("kubernetes.md", "Talos Linux on three nodes.", "Kubernetes Homelab Cluster")
neither = build_high_prompt([doc])
head = neither[: -len("</tools>")]
image = build_high_prompt([doc], show_image=True)
text = build_high_prompt([doc], show_text=True)
both = build_high_prompt([doc], show_image=True, show_text=True)
assert neither.endswith(TOOLS_SECTION)
assert image == head + SHOW_IMAGE_CLAUSE + "\n</tools>"
assert text == head + SHOW_TEXT_CLAUSE + "\n</tools>"
assert both == head + SHOW_IMAGE_CLAUSE + "\n\n" + SHOW_TEXT_CLAUSE + "\n</tools>"
assert SHOW_TEXT_CLAUSE not in image and SHOW_IMAGE_CLAUSE not in text
assert both.index(SHOW_IMAGE_CLAUSE) < both.index(SHOW_TEXT_CLAUSE)
for on in (image, text, both):
assert on[: on.index("<tools>")] == neither[: on.index("<tools>")]
assert on.count("<tools>") == 1 and on.count("</tools>") == 1
def test_high_prompt_empty_documents_show_text_clause_still_in_tools() -> None:
"""The "(no documents matched…)" body + ``show_text=True``: the clause
still lands inside ``<tools>`` — the grounded turn may still ``ls`` /
``grep`` its way to a file and display it."""
off = build_high_prompt([])
on = build_high_prompt([], show_text=True)
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
assert "(no documents matched — do not invent specifics)" in on
assert on.index("<tools>") < on.index(SHOW_TEXT_CLAUSE) < on.index("</tools>")
assert on.endswith(SHOW_TEXT_CLAUSE + "\n</tools>")
def test_show_text_clause_is_last_for_every_high_shape() -> None:
"""Steering + KB overview + folder lines + documents all present: the
section order is UNCHANGED (``<relevance>`` → ``<knowledge_base>`` →
``<tuning>`` → ``<documents>`` → ``<tools>``) and the text clause is
still the last thing inside ``<tools>`` before the closing tag."""
doc = _doc("deploy/k8s.md", "CONTENT_SENTINEL", "K8s Deploy", summary="K8s deploy notes.")
lines = ["Homelab/deploy/: k8s.md (1 doc)"]
off = build_high_prompt([doc], notes=["be concise"], kb_overview=OVERVIEW, folder_lines=lines)
on = build_high_prompt(
[doc],
notes=["be concise"],
kb_overview=OVERVIEW,
folder_lines=lines,
show_text=True,
)
assert on == off[: -len("</tools>")] + SHOW_TEXT_CLAUSE + "\n</tools>"
clause_start = on.index(SHOW_TEXT_CLAUSE)
assert on[on.index("<tools>") : clause_start] == TOOLS_SECTION[: -len("</tools>")]
for section in ("<relevance>", "<knowledge_base>", "<tuning>", "<documents>"):
assert on.index(section) < on.index("<tools>")
assert on.index("K8s deploy notes.") < on.index("<tools>")
assert on.endswith(SHOW_TEXT_CLAUSE + "\n</tools>")
assert SHOW_TEXT_CLAUSE not in off
def test_show_text_clause_rides_only_the_high_prompt() -> None:
"""Neither the tool name nor the fence language ever reaches the
deflection prompt (phase 182's D9: a deflected turn has no tools, and
resolving a locator there would leak KB text through a turn that just
disclaimed it) — and ``build_deflect_prompt`` takes no such flag."""
assert "show_text" not in inspect.signature(build_deflect_prompt).parameters
for prompt in (
build_deflect_prompt(["T1", "T2"]),
build_deflect_prompt(["T1"], notes=["be concise"], kb_overview=OVERVIEW),
build_deflect_prompt([]),
):
assert SHOW_TEXT_CLAUSE not in prompt
assert "show_text" not in prompt
assert not re.search(r"(?<![A-Za-z0-9_])bor(?![A-Za-z0-9_])", prompt)
# ---------- phase 125 (task 05, LOCKED A4): the image context line ----------
# An enriched image doc (tasks 03/04: non-NULL ``image_context`` in the
# "Referenced in …" block shape) leads its ``<document>`` suggestion
+33 -10
View File
@@ -63,6 +63,11 @@ from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
# Phase 182 (task 07): the ONE clipboard implementation moved to the shared
# verbatim panel client; the share path delegates to it, so the fallback's
# internals are pinned there (the owner-locked behavior is re-pinned, not
# dropped).
VERBATIM_JS = FRONTEND / "assets" / "verbatim.js"
# Phase 76 (task 02): SOURCES_HTML + GIT_SOURCES_HTML dropped — both
# views are folded into the shell (index.html); both files deleted.
INDEX_HTML = FRONTEND / "index.html"
@@ -533,18 +538,36 @@ def test_share_current_chat_save_then_share_branch() -> None:
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
# The clipboard + fallback helpers live in app.js (the chat page's
# copy of the per-page helper).
# Phase 182 (task 07): the clipboard + fallback pair MOVED to
# assets/verbatim.js so the answer's verbatim panels and ordinary code
# blocks share ONE degradation with the share link. The owner-locked
# behavior is unchanged and re-pinned on its new home: the same host (the
# composer, beside the status line — it rides the delegation), the same
# transient `.share-link-fallback` field, the same select-on-focus, the
# same XSS contract (textContent only), the same title string.
copy = _fn(js, "copyShareLinkWithFallback")
assert "navigator.clipboard.writeText(absoluteUrl)" in copy
assert 'field.className = "share-link-fallback"' in copy
assert "field.href = absoluteUrl" in copy
assert "field.textContent = absoluteUrl" in copy, "XSS contract: textContent only"
assert 'field.addEventListener("focus", () => selectAllInField(field))' in copy, (
"select-on-focus — the field-like behavior"
assert "copyTextWithFallback(" in copy, (
"the share path delegates — ONE clipboard implementation"
)
assert "composer.appendChild(field)" in copy, "near the status line (the composer)"
sel = _fn(js, "selectAllInField")
assert "absoluteUrl," in copy, "the shared URL is what gets copied"
assert "composer," in copy, "near the status line (the composer)"
assert '"Share link — click, then copy (Ctrl/Cmd+C)"' in copy, (
"the owner-locked field title rides the delegation byte-identically"
)
ver = VERBATIM_JS.read_text(encoding="utf-8")
impl = _fn(ver, "copyTextWithFallback")
assert "await navigator.clipboard.writeText(text)" in impl
assert 'field.className = "share-link-fallback"' in impl
assert "field.href = text" in impl, "a URL field carries the href"
assert "field.textContent = text" in impl, "XSS contract: textContent only"
assert (
'field.addEventListener("focus", () => selectAllInFallbackField(field))' in impl
), "select-on-focus — the field-like behavior"
assert "host.appendChild(field)" in impl, "appended to the caller's host"
assert (
'document.querySelectorAll(".share-link-fallback").forEach((el) => el.remove())' in impl
), "one field at a time (a new offer replaces the old)"
sel = _fn(ver, "selectAllInFallbackField")
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel

Some files were not shown because too many files have changed in this diff Show More