phase: 139_read_range_tool_lines
Build and Push Containers / build-and-push-app (push) Successful in 2m54s
Build and Push Containers / build-and-push-db (push) Successful in 17s

All verification is complete and green. Phase 139's three tasks (01–03) were already implemented in prior passes; this was the final verification pass, and every gate holds.

## Phase 139 — final verification report

**Verified in working tree (no code changes needed):**
- `readRangeSuffix` single-source-of-truth helper in `app.js` + byte-identical parity copy in `shared.js` (EN-dash ` (lines N–M)`, `(from line N)`, `(first N lines)`, `Number.isInteger(x) && x >= 1` guard mirroring the server's `_whole_line_number`)
- `appendToolLine(wrap, name, argument, offset = null, limit = null)` — `<span class="read-range">` sibling after `<code>`, only when suffix non-empty; `shared.js` `addToolLines` parity
- Live `tool` branch: guarded `ev.offset`/`ev.limit`, additive `toolAcc` records (non-range keeps pre-phase shape), status label carries the suffix (A2); restore path passes stored `t.offset`/`t.limit` through, phase-95 truncation note untouched
- E2E: `SAME_FILE_TRIGGER` mock flow (same file read twice, offsets 10/20 then 40/15) + 3-test Playwright suite

**Test / lint / coverage results:**
- `uv run pytest` (unit + integration): **3283 passed, 9 skipped, 0 failed**
- `uv run pytest --cov=app --cov-report=term-missing`: **TOTAL 99%** (>90% gate)
- `uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov`: **3 passed** in isolation
- `uv run ruff check .`: clean; `uv run pyright`: **0 errors, 0 warnings**

**Completion criteria:**
1. Range read renders `(lines N–M)` live; two same-file reads show DIFFERENT ranges — ✅ (E2E test 1)
2. Plain read byte-identical; phase-87/117 pins + emoji guard pass unedited — ✅ (E2E test 2 + full suite)
3. Suffix survives save → restore on chat page + shared page — ✅ (E2E test 3)
4. Full suite green, coverage >90%, E2E isolated green, ruff + pyright clean — ✅
5. Commit + phase move — left to the harness per executor rules (no `git add`/`commit` run)

**Notable:** the missing pytest summary line on one run was a transient output-buffering artifact, confirmed by re-run; DB was already up and healthy.

**Next pending phase:** `140_pictorial_pdf_pages`
This commit is contained in:
2026-09-30 12:55:23 -04:00
parent 1a657ce4ca
commit 9e7262a5c8
31 changed files with 2197 additions and 25 deletions
+213
View File
@@ -603,6 +603,49 @@ Implements just enough of the aipi surface:
never hit it); verified 2026-09-28: no existing E2E question or
fixture file contains the phrase, so every other suite is
unaffected.
- user message containing ``read the same file twice``
(``SAME_FILE_TRIGGER``, phase 139 task 03 — the read
range-tool-lines' dedicated story suite
``tests/e2e/test_read_range_tool_lines.py``) **and** the system
prompt carries the ``<tools>`` section -> the owner's exact
"same file over and over" scenario (TODO.md L4), end to end: the
model range-reads the SAME document TWICE with different
``offset``/``limit``, so the two Reading lines are distinguishable.
The question carries its own target after the colon (``read the
same file twice: read source/path`` — the question-carried-call
convention of ``READ_CAP_TRIGGER`` / ``DRILL_TRIGGER``), and the
step is then discriminated statelessly by the tool results in the
messages (the house count pattern — streaming only):
* request 1 (``tools`` offered, no read results yet): a
``tool_calls`` delta — ``read`` on the parsed target with
``offset`` = ``SAME_FILE_FIRST_OFFSET`` (10) and ``limit`` =
``SAME_FILE_FIRST_LIMIT`` (20) (id ``call_0``);
* a ``tool``-role read result is in the messages (the FIRST read
landed): the SECOND range read — a ``tool_calls`` delta —
``read`` on the SAME target with ``offset`` =
``SAME_FILE_SECOND_OFFSET`` (40) and ``limit`` =
``SAME_FILE_SECOND_LIMIT`` (15) (id ``call_1``) — the same
file again, a different range;
* TWO or more ``tool``-role read results are in the messages
(both range reads landed): the forced answer, byte-stable, in
the phase-131 read-echo shape — ``Read the same file twice —
lines 10–29: <q1>; lines 40–54: <q2>`` where each ``<qN>`` is
that read result's content with the header lines (the
phase-106 D5 ``date:`` line + the phase-131 ``lines: a-b of
N`` extent) skipped, first 80 chars (the phase-94 read-echo
convention — BOTH ranges reached the model's context). NOTE:
for the second read to PROCEED (not be refused
``ALREADY_IN_CONTEXT``) the document must already be in the
agent's truncated set — the story suite boots the app under
test with a ``BOR_READ_MAX_CHARS`` small enough that the
FIRST (20-line) range slice truncates, which is exactly the
large-document premise of the owner's confusion.
Checked BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger
phrases — the phase-71/72/94/118/131 ordering convention; the
trigger needs the ``<tools>`` section, so deflected turns never
hit it); verified 2026-09-30: no existing E2E question or
fixture file contains the phrase, so every other suite is
unaffected.
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
@@ -2283,6 +2326,121 @@ def _read_lines_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
return ("grep",)
# ---------------------------------------------------------------------------
# Phase 139 (task 03): the owner's "same file over and over" scenario —
# the model range-reads the SAME document TWICE with different
# offset/limit, so the two Reading lines are distinguishable. See the
# module docstring.
# ---------------------------------------------------------------------------
#: A user message containing this substring (case-insensitive) —
#: combined with the ``<tools>`` section in the system prompt — drives
#: the deterministic SAME-FILE-TWICE flow (the read range-tool-lines'
#: story suite, ``tests/e2e/test_read_range_tool_lines.py`` — the
#: owner's "same file over and over" confusion, TODO.md L4). Checked
#: BEFORE the plain ``TOOLS_TRIGGER`` flow (disjoint trigger phrases —
#: the phase-71/72/94/118/131 ordering convention); verified
#: 2026-09-30: no existing E2E question or fixture file contains the
#: phrase, so every other suite is unaffected.
SAME_FILE_TRIGGER = "read the same file twice"
#: The scripted target in the same-file-twice question (case-
#: insensitive — the suite's question capitalizes the trigger's first
#: letter): the verb (``read``) plus the target — a combined
#: ``source/path``, parsed from the RAW user message so the target
#: keeps its case. The target is a ``[a-z0-9_./-]`` run (case-
#: insensitively), so a `` — `` flavor separator can never bleed into
#: it (the read-cap / drill-down convention, ``_READ_CAP_CALL_RE`` /
#: ``_DRILL_CALL_RE``).
_SAME_FILE_CALL_RE = re.compile(
r"read the same file twice:\s*read\s+(?P<arg>[a-z0-9_./-]+)",
re.I,
)
#: The FIRST range read's requested extent — the model's REQUEST
#: (ASSUMPTION A1: the suffix shows the request, not the actual slice
#: extent). ``offset`` 10 + ``limit`` 20 renders `` (lines 10–29)``.
SAME_FILE_FIRST_OFFSET = 10
SAME_FILE_FIRST_LIMIT = 20
#: The SECOND range read's requested extent — the SAME file again, a
#: different range (the owner's confusion made visible). ``offset`` 40
#: + ``limit`` 15 renders `` (lines 40–54)``.
SAME_FILE_SECOND_OFFSET = 40
SAME_FILE_SECOND_LIMIT = 15
def _same_file_quote(doc_content: str) -> str:
"""The phase-94/131 read-echo quote of ONE read result's content.
The read result's header lines sit between the ``Document …:``
header and the document text — the phase-106 D5 ``date:`` line
always, the phase-131 ``lines: a-b of N`` extent on a range read
(a plain read carries no extent line) — skipped (the drill-flow
convention) so the quote is the pure read content, first 80 chars
(the RANGE reached the model's context). Mirrors the phase-131
``_read_lines_flow`` quote logic so the two flows' echo shapes
stay byte-consistent.
"""
content = doc_content
if content.startswith("date: "):
content = content.partition("\n")[2]
if content.startswith("lines: "):
content = content.partition("\n")[2]
return content[:80]
def _same_file_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
"""Classify a phase-139 same-file-twice request (see the module
docstring). The question carries its own target (``read the same
file twice: read source/path``); the step is then discriminated
statelessly by the READ results in the messages (the house count
pattern — the flow only ever issues ``read`` calls, so counting
read results is reliable):
* ``("answer", quote1, quote2)`` — TWO or more ``tool``-role read
results are in the messages (both range reads landed): the
forced answer, byte-stable, in the phase-131 read-echo shape —
``Read the same file twice — lines 10–29: <q1>; lines 40–54:
<q2>`` where each ``<qN>`` is that read result's pure content
(header lines skipped, first 80 chars — the :func:
``_same_file_quote`` convention).
* ``("read2", target)`` — EXACTLY ONE read result is in the
messages (the first read landed): the SECOND range read on the
SAME target — ``offset`` = :data:`SAME_FILE_SECOND_OFFSET`,
``limit`` = :data:`SAME_FILE_SECOND_LIMIT` (id ``call_1``).
* ``("read1", target)`` — ``tools`` are offered and no read result
is in the messages yet: the FIRST range read on the parsed
target — ``offset`` = :data:`SAME_FILE_FIRST_OFFSET`, ``limit``
= :data:`SAME_FILE_FIRST_LIMIT` (id ``call_0``).
* ``None`` — not the flow: the trigger is absent, the ``<tools>``
section is missing (deflected turns never carry it), the
target is unparseable, or ``tools`` are not offered and no read
result is in the messages yet (e.g. ``agent_max_rounds=0``).
"""
user = _user(body)
if SAME_FILE_TRIGGER not in user.lower():
return None
if "<tools>" not in _system(body):
return None
match = _SAME_FILE_CALL_RE.search(user)
if match is None:
return None
target = match.group("arg")
reads = _read_results(body)
if len(reads) >= 2:
return (
"answer",
_same_file_quote(reads[0][1]),
_same_file_quote(reads[1][1]),
)
if len(reads) == 1:
return ("read2", target)
if not body.get("tools"):
return None
return ("read1", target)
def long_answer() -> str:
"""~900-word deterministic walkthrough (phase 11): numbered steps plus
a unique final line that must survive the stream untruncated."""
@@ -3525,6 +3683,61 @@ def chat_completions(body: dict[str, Any]) -> Any:
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 139 (task 03): the owner's "same file over and over"
# scenario (the read range-tool-lines' story suite,
# tests/e2e/test_read_range_tool_lines.py — TODO.md L4): the
# model range-reads the SAME document TWICE with different
# offset/limit, so the two Reading lines are distinguishable.
# Checked BEFORE the plain TOOLS_TRIGGER flow (disjoint trigger
# phrase — the phase-71/72/94/118/131 ordering convention; the
# trigger needs the ``<tools>`` section, so deflected turns
# never hit it).
same_file = _same_file_flow(body)
if same_file is not None:
if same_file[0] == "read1":
# The FIRST range read on the parsed target — the
# model's REQUEST (ASSUMPTION A1: the suffix shows the
# request, not the actual slice extent).
stream = _tool_call_stream(
"read",
{
"path": same_file[1],
"offset": SAME_FILE_FIRST_OFFSET,
"limit": SAME_FILE_FIRST_LIMIT,
},
"call_0",
)
elif same_file[0] == "read2":
# The SECOND range read — the SAME file again, a
# different range (the owner's confusion made visible).
stream = _tool_call_stream(
"read",
{
"path": same_file[1],
"offset": SAME_FILE_SECOND_OFFSET,
"limit": SAME_FILE_SECOND_LIMIT,
},
"call_1",
)
else: # "answer" — the forced read-echo of BOTH ranges
stream = _sse_stream(
_apply_max_tokens(
f"Read the same file twice — lines "
f"{SAME_FILE_FIRST_OFFSET}–"
f"{SAME_FILE_FIRST_OFFSET + SAME_FILE_FIRST_LIMIT - 1}: "
f"{same_file[1]}; lines "
f"{SAME_FILE_SECOND_OFFSET}–"
f"{SAME_FILE_SECOND_OFFSET + SAME_FILE_SECOND_LIMIT - 1}: "
f"{same_file[2]}",
body.get("max_tokens"),
),
0.0,
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# Phase 124 (task 07, image chips and answers, TODO L3): the
# scripted SHOW_IMAGE flow — the mock shows the seeded image
# document (the ``<documents>`` block with the image-
+692
View File
@@ -0,0 +1,692 @@
"""Phase 139 task 03 E2E (Playwright, mock-only): the Reading tool line
shows WHICH lines are being read — the owner's "same file over and over"
confusion, end to end.
TODO.md L4: "The 'Reading' tool UI output doesn't show what lines are
being read, just the same file over and over which is confusing to the
user". When the agent range-reads a document (``read(path, offset,
limit)`` — phase 131), the Reading tool line now shows the REQUESTED
extent — ``📄 Reading <code>source/path</code> (lines 42–71)`` — so two
reads of the SAME file are distinguishable: live (the SSE ``tool``
frame's ``offset``/``limit``), on save (the additive ``bor.chat.v1``
record), and on restore (the ``/?chat=<id>`` boot load + the shared
page's parity render). A plain read renders byte-identical to
pre-phase (no ``.read-range`` span).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_read_range_tool_lines.py -v --no-cov
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — this story's
gate is the deterministic SCRIPTED flow in ``tests/e2e/mock_llm.py``
(``SAME_FILE_TRIGGER``, the owner's "same file over and over" scenario):
the model range-reads the SAME document TWICE with different
``offset``/``limit`` (10/20 then 40/15), and the answer echoes both
ranges (the phase-131 read-echo shape). The plain-read half (test 2)
reuses the mock's EXISTING ``READ_CAP_TRIGGER`` flow (``read the
capped document: read source/path`` — a plain read, no range).
The app under test boots with a SMALL ``BOR_READ_MAX_CHARS`` (the
env-override-for-the-app-under-test pattern, as in
``test_read_line_ranges.py``): the cap sits BETWEEN the two range
slices' lengths (the first 20-line slice truncates, the second 15-line
slice does not). That truncation is LOAD-BEARING, not incidental: the
agent refuses a second range read of a NON-truncated document that is
already in context (``ALREADY_IN_CONTEXT`` — the range is a subset of
the prompt). The first (truncated) range read puts the document in the
agent's truncated set, so the SECOND range read PROCEEDS and returns
its own slice — the owner's "same file over and over" with two real,
distinguishable ranges (the large-document premise of the confusion).
KB fixture — ONE deterministic 300-line markdown document
(``samedoc/twice.md``): line 1 is a marker line (the grounding words
the questions share — the mock-calibrated gate stays GROUNDED, the
``<tools>`` section is present, so both scripted flows fire); lines
2–300 are fixed-length filler (``Line <i>: …`` — the line numbers are
exact). The two range reads target lines 10–29 and 40–54 (the
mock's canned offsets/limits); the assertions use the very lines of
the string written to the fixture (never a literal), so the suite
stays true if the fixture is regenerated.
The app under test is mock-calibrated (conftest pattern):
``BOR_RELEVANCE_THRESHOLD=0.30`` + ``BOR_LEXICAL_SUPPORT_FLOOR=0.15``
+ ``BOR_SOURCE_USEFULNESS_FLOOR=0.15`` — the questions' best-chunk
cosine (the marker chunk wins) clears the floor with FTS hits (``twin``
/ ``ranges`` / ``documented`` in the marker line), so every scripted
turn stays GROUNDED.
Test → observable mapping (Playwright Mapping Rule; the story is the
owner's TODO item — one Playwright file per story, A16):
1. ``test_same_file_twice_shows_distinct_ranges`` — the marker
question (``read the same file twice: read samedoc/twice.md``) →
the SSE ``tool`` frames, IN ORDER, are the two range reads of the
SAME path with DISTINCT ranges (``offset`` 10/``limit`` 20, then
``offset`` 40/``limit`` 15 — asserted on the FRAME's args); the
answer echoes BOTH ranges (each range's first line reached the
model's context — the read-echo); and the UI shows TWO Reading
lines for the SAME ``<code>`` path carrying the DISTINCT suffixes
`` (lines 10–29)`` and `` (lines 40–54)`` (the owner's confusion is
gone).
2. ``test_plain_read_has_no_range_span`` — a plain-read flow (the
mock's existing ``READ_CAP_TRIGGER`` — a ``read`` with no range) →
the SSE ``tool`` frame carries NO ``offset``/``limit`` keys
(byte-identical to pre-phase) and the Reading line has NO
``.read-range`` span (the plain line's shape is untouched).
3. ``test_suffix_survives_save_restore`` — the marker question → the
live lines carry the suffixes → the conversation auto-saves (phase
55) → ``/?chat=<id>`` re-renders the saved record → the suffixes
come back (re-rendered from the saved record's additive
``offset``/``limit`` — no migration).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Locator, Page, expect
from sqlalchemy import select, text
from app.config import Settings as _Settings
from app.db import SessionLocal
from app.models import Document
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from e2e.mock_llm import (
SAME_FILE_FIRST_LIMIT,
SAME_FILE_FIRST_OFFSET,
SAME_FILE_SECOND_LIMIT,
SAME_FILE_SECOND_OFFSET,
)
REPO = Path(__file__).resolve().parents[2]
# Phase 79 (task 04, full inventory): the conftest session app owns
# its port in a combined run — this module app binds its own port
# instead (a same-port second uvicorn dies on bind and would drive
# the wrong server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_SAMEFILE", "8149"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
SOURCE = "samedoc" # the local directory's basename = the source name
REL = "twice.md"
SP = f"{SOURCE}/{REL}" # the combined read identity
#: The marker line (line 1) — the grounding words the questions share
#: (the mock-calibrated gate stays GROUNDED so both scripted flows
#: fire). It sits OUTSIDE both range slices (lines 10–29 / 40–54), so
#: it never pollutes the read-echo assertions.
MARKER_LINE = "SAMEFILE_MARKER the twin ranges of this document are documented here"
TOTAL_LINES = 300
def _doc_line(i: int) -> str:
"""One fixed-length filler line (``Line <i>: …``) — the line
numbers are exact and every line is the SAME length, so the range
slices' lengths are deterministic (20 lines > 15 lines)."""
prefix = f"Line {i:04d}: "
filler = "quiet mirror vault raid pool chain slot cycle guard probe audit "
return prefix + (filler * 5) [: 60 - len(prefix)]
def _doc() -> str:
"""The 300-line fixture document (deterministic — module
docstring): the marker line, then 299 fixed-length filler lines."""
lines = [MARKER_LINE]
lines.extend(_doc_line(i) for i in range(2, TOTAL_LINES + 1))
return "\n".join(lines)
DOC = _doc()
DOC_LINES = DOC.splitlines()
DOC_TOTAL = len(DOC)
assert len(DOC_LINES) == TOTAL_LINES
# The two range slices (1-based lines 10–29 and 40–54 — the mock's
# canned offsets/limits). Computed from the very string written to the
# fixture (the determinism pin — the assertions use these, never
# literals).
def _slice_lines(start: int, count: int) -> str:
"""The 1-based lines ``start``..``start+count-1`` joined (the range
read's slice — the mock's canned offsets/limits)."""
return "\n".join(DOC_LINES[start - 1 : start - 1 + count])
SLICE1 = _slice_lines(SAME_FILE_FIRST_OFFSET, SAME_FILE_FIRST_LIMIT)
SLICE2 = _slice_lines(SAME_FILE_SECOND_OFFSET, SAME_FILE_SECOND_LIMIT)
LEN1, LEN2 = len(SLICE1), len(SLICE2)
assert LEN2 < LEN1, (LEN2, LEN1)
# THE SUBJECT: the read cap sits BETWEEN the two slices' lengths — the
# first (20-line) range slice truncates (the document enters the agent's
# truncated set, so the SECOND range read proceeds instead of being
# refused ``ALREADY_IN_CONTEXT``), the second (15-line) slice does not.
READ_CAP = (LEN1 + LEN2) // 2
assert LEN2 <= READ_CAP < LEN1, (LEN2, READ_CAP, LEN1)
assert DOC_TOTAL > READ_CAP, "the plain read (test 2) must truncate too"
# The read-echo assertions: each range's FIRST line reached the model's
# context (a single-line substring — robust to the answer's markdown
# rendering). Line 10 is the first line of range 1; line 40 the first
# line of range 2.
R1_FIRST_LINE = DOC_LINES[SAME_FILE_FIRST_OFFSET - 1]
R2_FIRST_LINE = DOC_LINES[SAME_FILE_SECOND_OFFSET - 1]
assert R1_FIRST_LINE.startswith("Line 0010")
assert R2_FIRST_LINE.startswith("Line 0040")
# The expected suffixes (the readRangeSuffix contract — ASSUMPTION A1:
# the REQUESTED extent, offset–(offset+limit-1), en-dash per the pinned
# copy).
def _range_suffix(offset: int, limit: int) -> str:
"""The readRangeSuffix contract for a well-formed range (ASSUMPTION
A1: the REQUESTED extent, offset–(offset+limit-1), en-dash per the
pinned copy)."""
return f" (lines {offset}–{offset + limit - 1})"
SUFFIX1 = _range_suffix(SAME_FILE_FIRST_OFFSET, SAME_FILE_FIRST_LIMIT)
SUFFIX2 = _range_suffix(SAME_FILE_SECOND_OFFSET, SAME_FILE_SECOND_LIMIT)
assert SUFFIX1 == " (lines 10–29)"
assert SUFFIX2 == " (lines 40–54)"
# The scripted questions. Both carry the marker's grounding words (the
# mock-calibrated gate stays GROUNDED — the ``<tools>`` section is
# present, so both flows fire). Q_A / Q_C carry ``SAME_FILE_TRIGGER``
# + the target (the question-carried-call convention); Q_B carries the
# mock's existing ``READ_CAP_TRIGGER`` + the target (the plain read).
Q_A = f"Read the same file twice: read {SP} — what are the twin ranges documented here?"
Q_B = f"Read the capped document: read {SP} — what are the twin ranges documented here, plain read?"
Q_C = (
f"Read the same file twice: read {SP} — "
"what are the twin ranges documented here, restore check?"
)
# --------------------------------------------------------------------------
# Fixtures
# --------------------------------------------------------------------------
@pytest.fixture(scope="module")
def samedoc_dirs(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""The story's local-directory source: one host temp dir (the app
server runs on the same host, so the path is visible to it) holding
the ONE generated 300-line document. The directory's basename is
the source name (``kind=local``, phase 38)."""
root = tmp_path_factory.mktemp("bor_same_file") / SOURCE
root.mkdir()
(root / REL).write_text(DOC, encoding="utf-8")
assert (root / REL).read_text(encoding="utf-8") == DOC
assert not (root / ".git").exists() # the local-kind story: NOT git
return root
@pytest.fixture(scope="module")
def app_server(mock_llm: int, samedoc_dirs: Path) -> Iterator[str]:
"""The real app under test — per-module env (the conftest pattern,
cf. ``test_read_line_ranges.py``): NO ``BOR_GIT_SOURCES`` (the env
fallback is git-only — the source here is a DB-registered local
directory), the mock LLM, the mock-calibrated gate (threshold 0.30
+ lexical floor 0.15 — the conftest pair) — and THE SUBJECT: a
SMALL ``BOR_READ_MAX_CHARS`` (between the two range slices'
lengths) so the first range read truncates and the second range
read proceeds (the ``ALREADY_IN_CONTEXT`` gate — see the module
docstring). The session app is never started in this isolated run,
so no port clash."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated gate (conftest pattern): every scripted turn stays
# GROUNDED (the questions' best-chunk cosine clears the 0.15 floor
# with FTS hits — the marker chunk wins).
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
# The read document must clear the citation bar into done.sources.
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
# Phase 67: instant retry waits + the code-default budget (the
# conftest leak-guard pattern).
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# The repo's .env file carries the owner's settings (the app reads
# it from cwd) — override the source list with an EMPTY value (the
# env var beats the .env file): the registry must hold EXACTLY the
# local directory this suite registers.
env["BOR_GIT_SOURCES"] = ""
# Leak guards (conftest pattern).
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(
_Settings.model_fields["suggestions"].default
)
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
# THE SUBJECT: the small read cap (see the module docstring).
env["BOR_READ_MAX_CHARS"] = str(READ_CAP)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
def _truncate_all() -> None:
"""Fresh registry + KB (the E2E isolation pattern): the E2E suites
share one Postgres, so a leftover git_sources row or document would
pollute the retrieval the scripted turns run against (the
single-document KB's gate margins are pinned against EXACTLY this
one document)."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources, folder_summaries"
)
)
db.commit()
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
"""Poll the (cookie-authenticated) status endpoint until the run
reaches a terminal state (the test_ls_tree_drilldown pattern, over
plain httpx — this fixture has no browser page yet)."""
deadline = time.monotonic() + timeout_s
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = client.get("/api/sync/status")
assert r.status_code == 200, r.text
body = r.json()
if body["state"] in ("success", "failed"):
return body
time.sleep(0.5)
raise AssertionError(f"sync did not reach a terminal state: {body}")
@pytest.fixture(scope="module")
def synced_kb(app_server: str, samedoc_dirs: Path) -> None:
"""The story's precondition: the one-source KB synced under the
deterministic mock. Registers the temp directory through the
authenticated API (the ``test_local_directory_sources.py``
pattern), runs the REAL in-process sync (``POST /api/sync`` —
walk → chunk → embed → overview → version bump), and pins the
stored content byte-identical to the fixture string."""
_truncate_all()
with httpx.Client(base_url=app_server, timeout=30.0) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, r.text
r = client.post(
"/api/git-sources", json={"kind": "local", "path": str(samedoc_dirs)}
)
assert r.status_code == 201, r.text
r = client.post("/api/sync")
assert r.status_code == 202, r.text
body = _wait_sync_done_http(client)
assert body["state"] == "success", body
detail = body["detail"]
assert detail["added"] == 1, detail
assert detail["pruned"] == 0, detail
# The import stored the fixture string BYTE-IDENTICALLY — the slice
# lengths every assertion uses are ``len()`` of the very string
# written to the directory.
with SessionLocal() as db:
stored = db.scalar(
select(Document.content).where(
Document.source == SOURCE, Document.path == REL
)
)
assert stored == DOC, "stored content drifted from the fixture"
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
"""Per-test query_log isolation (the KB itself is module-scoped —
the scripted turns never change it, so the registry persists
across the tests of this module)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE query_log"))
db.commit()
yield
with SessionLocal() as db:
db.execute(text("TRUNCATE query_log"))
db.commit()
# --------------------------------------------------------------------------
# Page + API helpers (the test_read_line_ranges / test_chat_history
# house patterns)
# --------------------------------------------------------------------------
#: Captures the raw SSE ``data:`` payloads of the /api/chat stream
#: (a response clone read in the background) — wire-level assertions
#: for the ``tool`` frames, independent of the UI rendering.
SSE_HOOK = """
() => {
if (window.__sseInstalled) return;
window.__sseInstalled = true;
window.__sseFrames = [];
const origFetch = window.fetch;
window.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0].url;
if (url.includes('/api/chat')) {
res.clone().text().then((bodyText) => {
for (const block of bodyText.split('\\n\\n')) {
const line = block.trim();
if (line.startsWith('data: ')) {
window.__sseFrames.push(line.slice(6));
}
}
});
}
} catch (e) { /* non-clonable responses: ignored */ }
return res;
};
}
"""
def _install_page_hooks(page: Page) -> None:
page.evaluate(SSE_HOOK)
def _frames(page: Page, deadline_s: float = 120.0) -> list[dict]:
"""The SSE frames captured since the last submit (``_submit``
clears the buffer), once the hook's background read settles."""
deadline = time.monotonic() + deadline_s
while True:
raw = page.evaluate("() => window.__sseFrames || []")
parsed = [json.loads(line) for line in raw if line]
if any(f.get("type") == "done" for f in parsed):
return parsed
if time.monotonic() > deadline:
raise AssertionError(
f"SSE hook captured no `done` frame (frames so far: "
f"{len(parsed)}) — hook install failed?"
)
time.sleep(0.05)
def _tool_frames(frames: list[dict]) -> list[dict]:
return [f for f in frames if f.get("type") == "tool"]
def _submit(page: Page, question: str) -> None:
page.evaluate("window.__sseFrames = []")
page.fill("#message-input", question)
page.click("#send-btn")
# The user bubble lands synchronously with the submit handler.
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
def _wait_settled(page: Page, timeout_ms: int = 120_000) -> None:
"""The turn is complete: answer text in the bubble, button
recovered (the phase-48 settle wait)."""
expect(page.locator(".msg.brain .bubble").last).not_to_have_text(
"", timeout=timeout_ms
)
expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout_ms)
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout_ms)
def _last_brain(page: Page) -> Locator:
return page.locator(".msg.brain").last
def _reading_lines(page: Page) -> Locator:
"""The Reading lines for the fixture document (the ``.tool-call``
lines whose ``<code>`` child carries the combined identity)."""
return page.locator(".tool-call", has=page.locator(f"code:has-text('{SP}')"))
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form
login — used to call the admin API with plain httpx."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
return next((c for c in rows if c["title"] == title), None)
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _wait_saved_row(
app_url: str, cookies: dict[str, str], title: str, messages: int = 2
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
so there is no status line to wait on). Poll the admin list until
the row with the conversation's auto-title appears with the
expected message count."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(_chats(app_url, cookies), title)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
# --------------------------------------------------------------------------
# 1. The owner's "same file over and over": two range reads of the SAME
# file show DIFFERENT ranges (live, on the wire, and in the answer)
# --------------------------------------------------------------------------
def test_same_file_twice_shows_distinct_ranges(
page: Page, app_url: str, synced_kb: None, db_ready: None
) -> None:
page.set_default_timeout(60_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_A)
_wait_settled(page)
frames = _frames(page)
# The owner's "same file over and over" on the wire — IN ORDER,
# asserted on the frames' ARGS (not a substring): the SAME path,
# TWO distinct ranges (offset 10/limit 20, then offset 40/limit 15
# — the phase-131 additive fields ride the SAME frame).
assert _tool_frames(frames) == [
{
"type": "tool",
"name": "read",
"argument": SP,
"offset": SAME_FILE_FIRST_OFFSET,
"limit": SAME_FILE_FIRST_LIMIT,
},
{
"type": "tool",
"name": "read",
"argument": SP,
"offset": SAME_FILE_SECOND_OFFSET,
"limit": SAME_FILE_SECOND_LIMIT,
},
], _tool_frames(frames)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False, done
# The read document is the turn's cited source (phase 119 A1).
assert any(
s["path"] == REL and s["source"] == SOURCE for s in done["sources"]
), done["sources"]
# The GROUNDED echo — BOTH ranges reached the model's context (the
# phase-131 read-echo shape: each range's first line lands in the
# answer — line 10 for range 1, line 40 for range 2).
text = _last_brain(page).locator(".bubble").text_content() or ""
assert R1_FIRST_LINE[:10] in text, f"range 1 not in the answer: {text!r}"
assert R2_FIRST_LINE[:10] in text, f"range 2 not in the answer: {text!r}"
# The echo names BOTH requested ranges (the en-dash, the pinned
# copy).
assert "lines 10–29" in text, text
assert "lines 40–54" in text, text
# THE SUBJECT: TWO Reading lines for the SAME <code> path carry the
# DISTINCT suffixes — the owner's confusion is gone.
lines = _reading_lines(page)
expect(lines).to_have_count(2)
codes = [lines.nth(i).locator("code").text_content() for i in range(2)]
assert codes == [SP, SP], f"both lines must read the SAME path: {codes}"
ranges = [
lines.nth(i).locator(".read-range").text_content() for i in range(2)
]
assert ranges == [SUFFIX1, SUFFIX2], f"the suffixes must differ: {ranges}"
assert ranges[0] != ranges[1], "two reads of one file must be distinguishable"
# --------------------------------------------------------------------------
# 2. A plain read (no range) renders byte-identical to pre-phase: the
# tool frame carries no range keys and the line has no .read-range
# span
# --------------------------------------------------------------------------
def test_plain_read_has_no_range_span(
page: Page, app_url: str, synced_kb: None, db_ready: None
) -> None:
page.set_default_timeout(60_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_B)
_wait_settled(page)
frames = _frames(page)
# The plain read on the wire: the tool frame carries NO
# offset/limit keys (a plain read — the phase-131 additive fields
# are ABSENT, never null — byte-identical to pre-phase).
assert _tool_frames(frames) == [
{"type": "tool", "name": "read", "argument": SP}
], _tool_frames(frames)
done = next(f for f in frames if f.get("type") == "done")
assert done["deflected"] is False, done
# THE SUBJECT: the Reading line has NO .read-range span (the plain
# line's shape is untouched — byte-identical to pre-phase). The
# line still carries the pinned "📄 Reading " prefix + the <code>
# path (and, for this large document, the orthogonal phase-95
# truncation note — which is NOT a range span).
lines = _reading_lines(page)
expect(lines).to_have_count(1)
expect(lines.first.locator(".read-range")).to_have_count(0)
line_text = lines.first.text_content() or ""
assert line_text.startswith(f"📄 Reading {SP}"), line_text
# No range-suffix wording of any shape (lines / from line / first
# N lines).
assert " (lines " not in line_text, line_text
assert " (from line " not in line_text, line_text
assert " (first " not in line_text, line_text
# --------------------------------------------------------------------------
# 3. The suffix survives save → restore: the saved record's additive
# offset/limit re-render the same suffixes on /?chat=<id>
# --------------------------------------------------------------------------
def test_suffix_survives_save_restore(
page: Page, app_url: str, synced_kb: None, db_ready: None
) -> None:
page.set_default_timeout(60_000)
login(page, app_url, next="/")
_install_page_hooks(page)
_submit(page, Q_C)
_wait_settled(page)
# The LIVE lines carry the suffixes (the save point persists the
# toolAcc records — {name, argument, offset, limit}).
lines = _reading_lines(page)
expect(lines).to_have_count(2)
live_ranges = [
lines.nth(i).locator(".read-range").text_content() for i in range(2)
]
assert live_ranges == [SUFFIX1, SUFFIX2], live_ranges
# The conversation auto-saved (phase 55 — silent). Wait for the row
# by its auto-title (the first question).
cookies = _admin_cookies(page)
title = _auto_title(Q_C)
row = _wait_saved_row(app_url, cookies, title)
chat_id: str = row["id"]
try:
# Restore: /?chat=<id> boots into the saved conversation through
# the SAME renderStoredMessage loop as the local restore — the
# saved record's additive offset/limit re-render the suffixes.
page.goto(app_url + f"/?chat={chat_id}")
restored = _reading_lines(page)
expect(restored).to_have_count(2, timeout=30_000)
codes = [restored.nth(i).locator("code").text_content() for i in range(2)]
assert codes == [SP, SP], f"restored lines must read the SAME path: {codes}"
ranges = [
restored.nth(i).locator(".read-range").text_content() for i in range(2)
]
assert ranges == [SUFFIX1, SUFFIX2], (
f"the suffixes must re-render from the saved record: {ranges}"
)
# The answer text restored too (the record is intact).
expect(_last_brain(page).locator(".bubble")).to_contain_text(
R1_FIRST_LINE[:10], timeout=15_000
)
finally:
_delete_chat(app_url, cookies, chat_id)
+10
View File
@@ -730,6 +730,9 @@ def test_truncated_read_save_and_shared_fidelity(
detail = _get_chat(app_url, cookies, chat_id)
brain_msgs = [m for m in detail["messages"] if m["who"] == "brain"]
assert len(brain_msgs) == 1, detail["messages"]
# The full CURRENT shape (phase 139: + the additive range
# fields, null for a plain read — the mock's read is not a
# range read, so the record carries no range).
assert brain_msgs[0]["tools"] == [
{
"name": "read",
@@ -737,6 +740,8 @@ def test_truncated_read_save_and_shared_fidelity(
"truncated": True,
"chars_shown": READ_CAP,
"chars_total": CAP_DOC_TOTAL,
"offset": None,
"limit": None,
}
], brain_msgs[0]["tools"]
@@ -842,6 +847,11 @@ def test_short_read_control_no_frame_no_marker(
"truncated": False,
"chars_shown": None,
"chars_total": None,
# Phase 139: the additive range fields, null for a
# plain read (the pre-phase 5-key shape still
# validates — stored as nulls, rendered suffix-less).
"offset": None,
"limit": None,
}
], brain_msgs[0]["tools"]
finally:
+13
View File
@@ -59,6 +59,7 @@ SHARED_OUT_KEYS = {"title", "messages"}
#: Phase 95 (task 02): the CURRENT full tool-entry shape — the additive
#: truncation fields ride each record (a pre-phase-95 entry WITHOUT them
#: still validates — the backward-compat pin in test_schemas.py).
#: Phase 139 (task 02): the additive range fields join the same entries.
FULL_BRAIN: dict[str, Any] = {
"who": "brain",
"text": "Your k3s cluster runs on three nodes — you've got this.",
@@ -78,6 +79,12 @@ FULL_BRAIN: dict[str, Any] = {
"deflected": False,
"suggestions": ["What ports does Traefik expose?"],
"thinking": "The kubernetes doc covers the cluster layout…",
# Phase 139 (task 02): the read's requested range joins the same
# entries (additive — the truncation-fields precedent above): a
# save → restore round-trip keeps it, so the restore path and the
# shared page re-render the "(lines 42–71)" suffix. A pre-phase-139
# entry WITHOUT the keys still validates (test_schemas.py's
# backward-compat pin) and stores nulls.
"tools": [
{
"name": "read",
@@ -85,6 +92,8 @@ FULL_BRAIN: dict[str, Any] = {
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
"offset": 42,
"limit": 30,
},
{
"name": "ls",
@@ -92,6 +101,8 @@ FULL_BRAIN: dict[str, Any] = {
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
},
# Saved chats persisting the pre-phase-70 tool names still
# validate — ``name`` is opaque to the API (no migration,
@@ -102,6 +113,8 @@ FULL_BRAIN: dict[str, Any] = {
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
},
],
"stopped": False,
+1 -1
View File
@@ -197,7 +197,7 @@ def test_arm_has_exactly_one_live_call_site() -> None:
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
append = branch.find("appendToolLine(wrap, name, argument)")
append = branch.find("appendToolLine(wrap, name, argument, frameOffset, frameLimit)")
arm = branch.find("armToolLineClock(wrap)")
assert -1 < append < arm, (
"the arm follows the line's append — the baseline resets per line"
+8 -4
View File
@@ -142,10 +142,12 @@ def test_record_without_the_new_keys_validates_with_none() -> None:
def test_pre_phase_record_round_trips_byte_identical_on_existing_keys() -> None:
"""The phase-50 contract through the new schema: a pre-phase-120
STORED record (every phase-50 key present, explicit nulls where an
optional key does not apply) validates, and re-serializes
byte-identically on EVERY pre-phase key — the only diff is the two
added keys as explicit nulls. Old saved chats and shared links
therefore render/restore exactly as before."""
optional key does not apply — tool entries in the CURRENT full
shape, the phase-95/139 additive fields as nulls for a plain read)
validates, and re-serializes byte-identically on EVERY pre-phase
key — the only diff is the two added keys as explicit nulls. Old
saved chats and shared links therefore render/restore exactly as
before."""
pre_phase: dict = {
"who": "brain",
"text": "Your k3s cluster runs on three nodes — you've got this.",
@@ -161,6 +163,8 @@ def test_pre_phase_record_round_trips_byte_identical_on_existing_keys() -> None:
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
],
"stopped": None,
+4 -1
View File
@@ -161,7 +161,10 @@ def test_app_js_labels_resolve_the_name_at_call_time() -> None:
assert "`${brand()} is thinking`" in js
assert "`${brand()} is answering`" in js
assert "`${brand()} is still thinking (${secs}s)`" in js
assert "`${brand()} is reading ${argument}`" in js
assert (
"`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`"
in js
), ("phase 139: the read status carries the requested-range suffix (A2)")
assert "`${brand()} is listing documents`" in js
# The frozen module-level literals must be gone (stale-name bug).
assert '"Brain of Reese is thinking"' not in js
+13 -5
View File
@@ -50,17 +50,19 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None:
"the turn handler must branch on tool frames"
)
branch = js[tool_idx:delta_idx]
assert "toolAcc.push({ name, argument })" in branch, (
assert "toolAcc.push(toolRecord)" in branch, (
"every tool frame is recorded for persistence — the record stays"
" {name, argument}-generic, no per-tool shape (phase 68: the"
" search tool rides the same accumulator)"
" search tool rides the same accumulator; phase 139: the range"
" fields join additively — a non-range record keeps the exact"
" pre-phase shape)"
)
assert "clearTurnTimeout()" in branch, "a tool frame proves the stream is alive"
assert 'addMessage("brain", "")' in branch, "first frame creates the brain wrap"
assert "uiState === UI_STATE.thinking" in branch, (
"label updates only while the state is still thinking"
)
assert "appendToolLine(wrap, name, argument)" in branch
assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch
# No setUiState in the branch: the state stays "thinking" (never stale).
assert "setUiState" not in branch, (
"the tool branch must keep uiState=thinking (button stays disabled)"
@@ -86,7 +88,10 @@ def test_calling_tool_label_strings() -> None:
branch = js[tool_idx:delta_idx]
assert "sendLabel" not in branch, "phase 48: the button keeps its Stop label"
assert "`${brand()} is listing documents`" in branch
assert "`${brand()} is reading ${argument}`" in branch
assert (
"`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`"
in branch
), ("phase 139: the read status carries the requested-range suffix (A2)")
# Phase 70: the read status — new + legacy name, locked
# name+argument gate, first in the ternary.
assert 'name === "read" || name === "read_document") && argument' in branch, (
@@ -222,7 +227,10 @@ def test_tool_lines_re_render_on_restore() -> None:
end = js.find("function restoreConversation")
body = js[fn:end]
assert "Array.isArray(m.tools)" in body
assert "appendToolLine(wrap, t.name, arg)" in body
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, (
"phase 139: the stored range rides the SAME helper — a pre-phase"
" record (no fields) renders byte-identical"
)
thinking_restore = body.find("if (m.thinking)")
tools_restore = body.find("Array.isArray(m.tools)")
assert -1 < thinking_restore < tools_restore, (
+497
View File
@@ -0,0 +1,497 @@
"""Unit: the phase-139 (task 03) "the Reading line shows which lines"
frontend contract.
No new Python app logic exists for this task — the behavior lives in
``frontend/assets/app.js`` (the ``readRangeSuffix`` helper + the
``appendToolLine`` suffix span + the live ``tool`` branch's
``ev.offset``/``ev.limit`` read + the ``toolAcc`` persistence stamp +
the restore pass-through) and ``frontend/assets/shared.js`` (the shared
page's parity copy of the suffix + ``addToolLines``). Like the other
frontend-adjacent unit files (``test_frontend_tool_states.py`` is the
phase-37 precedent, ``test_read_truncation_frontend.py`` the phase-95
one), this module pins the JS markers the story depends on so a silent
regression in the suffix wording, the well-formedness guard, the
persistence shape, or the shared-page parity is caught without a
browser. A small node-driven behavioral check (skipped when node is
unavailable, like the phase-10 renderer check in
``test_document_viewer.py``) proves the three suffix shapes, the
plain-read line's byte-identity to pre-phase, and the shared-page
parity. The E2E gate is the phase's story suite
(``tests/e2e/test_read_range_tool_lines.py``).
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
SHARED_JS = FRONTEND / "assets" / "shared.js"
HAVE_NODE = shutil.which("node") is not None
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _shared_js() -> str:
return SHARED_JS.read_text(encoding="utf-8")
def _extract(js: str, name: str) -> str:
"""One top-level function by name — from its ``function NAME`` to
its column-0 closing ``}`` (inner braces are indented, so the first
``\\n}\\n`` is the function's own end — the
``test_frontend_tool_states`` extraction convention)."""
fn = js.find(f"function {name}")
assert fn != -1, f"{name} not found"
end = js.find("\n}\n", fn)
assert end != -1, f"could not find the end of {name}"
return js[fn : end + 2]
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
# --------------------------------------------------------------------------
# app.js — readRangeSuffix: the single source of truth for the wording
# --------------------------------------------------------------------------
def test_read_range_suffix_exists_with_the_pinned_literals() -> None:
"""readRangeSuffix (app.js): the well-formedness guard mirrors the
server's ``_whole_line_number`` (``Number.isInteger(x) && x >= 1``
— bools are not integers in JS), and the three suffix shapes carry
the EXACT pinned copy — both present → `` (lines {offset}–{offset +
limit - 1})`` (EN-DASH, ASSUMPTION A1: the REQUESTED extent),
offset only → `` (from line {offset})``, limit only →
`` (first {limit} lines)``, otherwise `` ""``."""
body = _extract(_js(), "readRangeSuffix")
assert "Number.isInteger(offset) && offset >= 1" in body, (
"the offset guard mirrors the server's _whole_line_number"
)
assert "Number.isInteger(limit) && limit >= 1" in body, (
"the limit guard mirrors the server's _whole_line_number"
)
assert "` (lines ${offset}–${offset + limit - 1})`" in body, (
"the both-present shape — the REQUESTED extent, en-dash per the"
" pinned copy (ASSUMPTION A1)"
)
assert "` (from line ${offset})`" in body, "the offset-only shape"
assert "` (first ${limit} lines)`" in body, "the limit-only shape"
assert 'return ""' in body, "neither field well-formed → no suffix"
def test_read_range_suffix_shape_order() -> None:
"""The both-present shape wins (it is checked first), then
offset-only, then limit-only, then the empty fallback — so a
well-formed pair always renders the range, never a one-sided
shape."""
body = _extract(_js(), "readRangeSuffix")
both = body.find("if (offOk && limOk)")
off_only = body.find("if (offOk)")
lim_only = body.find("if (limOk)")
empty = body.find('return ""')
assert -1 < both < off_only < lim_only < empty, (
"both → offset-only → limit-only → empty, in order"
)
# --------------------------------------------------------------------------
# app.js — appendToolLine: the suffix span on the Reading line
# --------------------------------------------------------------------------
def test_append_tool_line_signature_takes_optional_range() -> None:
"""appendToolLine gains the additive ``offset``/``limit`` params
(default ``null`` — a pre-phase 3-arg call renders byte-identical),
and the read arm renders the suffix through the SAME helper."""
body = _extract(_js(), "appendToolLine")
assert (
"function appendToolLine(wrap, name, argument, offset = null, limit = null)"
in _js()
), "the signature carries the additive offset/limit (default null)"
assert "readRangeSuffix(offset, limit)" in body, (
"the read arm renders the suffix through the SAME helper"
)
assert 'range.className = "read-range"' in body, (
"the suffix rides a .read-range span"
)
assert "range.textContent = rangeSuffix" in body, (
"the range is data — textContent, never innerHTML"
)
assert "if (rangeSuffix)" in body, (
"the span is appended ONLY when the suffix is non-empty — a"
" plain read's line stays byte-identical"
)
def test_append_tool_line_suffix_is_a_sibling_after_the_code() -> None:
"""The .read-range span is a SIBLING appended AFTER the ``<code>``
child (the phase-95 truncation-note precedent — append a span,
never rewrite the pinned template text), so the line's pinned
``📄 Reading `` + ``<code>`` bytes are untouched."""
body = _extract(_js(), "appendToolLine")
code_append = body.find("line.appendChild(code)")
range_append = body.find("line.appendChild(range)")
assert -1 < code_append < range_append, (
"the range span lands AFTER the <code> child"
)
# The read arm's pinned template text is untouched (phase-87/117).
assert 'line.textContent = "📄 Reading "' in body
assert "innerHTML" not in body, (
"no HTML injection surface on tool lines — textContent only"
)
# --------------------------------------------------------------------------
# app.js — the live `tool` branch: ev.offset/ev.limit + the toolAcc stamp
# --------------------------------------------------------------------------
def test_live_tool_branch_reads_the_range_with_the_guard() -> None:
"""The live ``tool`` branch reads ``ev.offset`` / ``ev.limit`` with
the SAME well-formedness guard (``Number.isInteger(x) && x >= 1``
→ ``null`` when absent / malformed), so a non-range frame keeps the
exact pre-phase ``{name, argument}`` record shape (the additive
``bor.chat.v1`` JSONB — old records restore byte-identically, no
migration)."""
js = _js()
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert "Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null" in branch, (
"ev.offset reads through the house guard → null when absent"
)
assert "Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null" in branch, (
"ev.limit reads through the house guard → null when absent"
)
# The record carries the fields ONLY when non-null.
assert "const toolRecord = { name, argument }" in branch, (
"the base record stays {name, argument}-generic"
)
assert "if (frameOffset !== null) toolRecord.offset = frameOffset" in branch, (
"offset joins the record ONLY when non-null"
)
assert "if (frameLimit !== null) toolRecord.limit = frameLimit" in branch, (
"limit joins the record ONLY when non-null"
)
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
# Both values pass to the SAME helper (the line + the status label).
assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch
def test_live_read_status_carries_the_range_suffix() -> None:
"""ASSUMPTION A2: the read status label carries the SAME
requested-range suffix as the line (``readRangeSuffix`` — the single
source of truth); a non-range read renders byte-identical (the
helper returns ``""``). The grep / ls labels are untouched."""
js = _js()
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert (
"`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`"
in branch
), "the read status carries the suffix (A2)"
# The other labels stay byte-identical (no suffix).
assert "`${brand()} is searching for ${argument}`" in branch
assert "`${brand()} is listing documents in ${argument}`" in branch
assert "`${brand()} is listing documents`" in branch
# --------------------------------------------------------------------------
# app.js — the restore path: the stored range re-renders the suffix
# --------------------------------------------------------------------------
def test_restore_path_passes_the_stored_range_through() -> None:
"""The phase-14 restore path (renderStoredMessage): a stored tool
record with ``offset``/``limit`` (the live ``toolAcc`` stamp —
additive on ``bor.chat.v1``) re-renders the SAME suffix through the
SAME helper; a record saved before the phase (no fields) renders
byte-identically (the guard → null → no span)."""
js = _js()
fn = js.find("function renderStoredMessage")
assert fn != -1
end = js.find("function restoreConversation")
body = js[fn:end]
assert "Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null" in body, (
"the stored offset reads through the house guard"
)
assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body, (
"the stored limit reads through the house guard"
)
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, (
"the stored range rides the SAME helper"
)
# --------------------------------------------------------------------------
# shared.js — the parity copy: identical literals + the addToolLines span
# --------------------------------------------------------------------------
def test_shared_page_suffix_is_byte_identical_to_app_js() -> None:
"""shared.js's ``readRangeSuffix`` is the parity copy — IDENTICAL
literals and IDENTICAL guard (the phase-50 restore contract: the
shared page re-renders saved records pixel-identically to the chat
page). The three suffix shapes + the guard must match app.js
EXACTLY."""
shared = _extract(_shared_js(), "readRangeSuffix")
app = _extract(_js(), "readRangeSuffix")
for literal in (
"Number.isInteger(offset) && offset >= 1",
"Number.isInteger(limit) && limit >= 1",
"` (lines ${offset}–${offset + limit - 1})`",
"` (from line ${offset})`",
"` (first ${limit} lines)`",
'return ""',
):
assert literal in shared, f"shared.js lost the parity literal {literal!r}"
assert literal in app, f"app.js lost the parity literal {literal!r}"
def test_shared_page_add_tool_lines_renders_the_range_span() -> None:
"""shared.js's ``addToolLines``: the SAME .read-range span as the
chat page's ``appendToolLine`` — a sibling after the ``<code>``
child, textContent only, appended ONLY when the suffix is non-empty
(a record without the fields renders exactly as today)."""
body = _extract(_shared_js(), "addToolLines")
assert "readRangeSuffix(t.offset, t.limit)" in body, (
"the shared page renders the suffix from the stored record"
)
assert 'range.className = "read-range"' in body
assert "range.textContent = rangeSuffix" in body
assert "if (rangeSuffix)" in body
code_append = body.find("line.appendChild(code)")
range_append = body.find("line.appendChild(range)")
assert -1 < code_append < range_append, (
"the shared range span lands AFTER the <code> child (parity)"
)
assert "innerHTML" not in body, (
"no HTML injection surface on shared tool lines — textContent only"
)
def test_no_cdn_added() -> None:
"""AGENTS.md rule 6: the range suffix adds no external
script/link."""
index = (FRONTEND / "index.html").read_text(encoding="utf-8")
assert 'src="http' not in index and 'href="http' not in index
# --------------------------------------------------------------------------
# behavior (node): the three suffix shapes, the plain line's
# byte-identity, and the shared-page parity
# --------------------------------------------------------------------------
#: A minimal DOM stub — just enough surface for appendToolLine /
#: addToolLines (createElement / querySelector / appendChild /
#: insertBefore / textContent / className / setAttribute). Plain strings
#: only; every node is snapshotted as a JSON row (the
#: test-document-viewer / test-chat-image-questions pattern).
_DOM_STUB = """
class El {
constructor(tag) {
this.tag = tag;
this.children = [];
this.attrs = {};
this.className = "";
this._text = "";
this.open = false;
}
setAttribute(k, v) { this.attrs[k] = String(v); }
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
appendChild(c) { this.children.push(c); return c; }
insertBefore(c, ref) {
if (!ref) { this.children.push(c); return c; }
const i = this.children.indexOf(ref);
if (i === -1) this.children.push(c); else this.children.splice(i, 0, c);
return c;
}
_match(el, sel) {
if (sel.charAt(0) === ".")
return (el.className || "").split(/\\s+/).indexOf(sel.slice(1)) !== -1;
if (sel.charAt(0) === "#") return el.id === sel.slice(1);
return el.tag === sel;
}
querySelector(sel) {
for (const c of this.children) {
if (this._match(c, sel)) return c;
const r = c.querySelector(sel);
if (r) return r;
}
return null;
}
querySelectorAll(sel) {
const out = [];
for (const c of this.children) {
if (this._match(c, sel)) out.push(c);
out.push(...c.querySelectorAll(sel));
}
return out;
}
get textContent() {
let t = this._text;
for (const c of this.children) t += c.textContent;
return t;
}
set textContent(v) { this._text = String(v); this.children = []; }
}
const document = { createElement: (t) => new El(t) };
function makeWrap() {
const wrap = document.createElement("div");
const body = document.createElement("div");
body.className = "msg-body";
const bubble = document.createElement("div");
bubble.className = "bubble";
body.appendChild(bubble);
wrap.appendChild(body);
return wrap;
}
function describe(el) {
return {
text: el.textContent,
children: el.children.map(
(c) => ({ tag: c.tag, cls: c.className, text: c.textContent })
),
};
}
"""
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_suffix_shapes_under_node() -> None:
"""The REAL app.js ``readRangeSuffix`` under node — the three suffix
shapes + the malformed fallbacks (the guard: bools / strings / 0 are
not whole line numbers ≥ 1; a one-sided well-formed pair renders its
one-sided shape; a well-formed pair renders the range)."""
fn = _extract(_js(), "readRangeSuffix")
cases = [
[42, 30, " (lines 42–71)"],
[42, None, " (from line 42)"],
[None, 5, " (first 5 lines)"],
[None, None, ""],
["x", 5, " (first 5 lines)"], # malformed offset, valid limit
[42, "y", " (from line 42)"], # valid offset, malformed limit
[0, 5, " (first 5 lines)"], # offset 0 is not ≥ 1
[42, 0, " (from line 42)"], # limit 0 is not ≥ 1
[True, 5, " (first 5 lines)"], # a bool is not an integer
[1, 1, " (lines 1–1)"], # the smallest well-formed range
]
driver = (
fn
+ "\nconst cases = "
+ json.dumps([[c[0], c[1]] for c in cases])
+ ";\nconsole.log(JSON.stringify(cases.map((c) => readRangeSuffix(c[0], c[1]))));"
)
out = json.loads(_run_node(driver))
assert out == [c[2] for c in cases], out
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_plain_line_byte_identical_and_range_line_carries_the_suffix() -> None:
"""The REAL app.js ``appendToolLine`` under node (against the DOM
stub): a PLAIN read (no offset/limit) renders the pre-phase line
byte-identical — ``📄 Reading <code>path</code>``, a single ``code``
child, NO ``.read-range`` span — while a RANGE read appends the
``.read-range`` sibling after the ``code`` with the exact suffix."""
suffix = _extract(_js(), "readRangeSuffix")
append = _extract(_js(), "appendToolLine")
driver = (
_DOM_STUB
+ "\n"
+ suffix
+ "\n"
+ append
+ '\nconst ARG = "samedoc/twice.md";\n'
+ "const w1 = makeWrap();\n"
+ 'appendToolLine(w1, "read", ARG);\n'
+ 'const plain = describe(w1.querySelector(".tool-call"));\n'
+ "const w2 = makeWrap();\n"
+ 'appendToolLine(w2, "read", ARG, 10, 20);\n'
+ 'const range = describe(w2.querySelector(".tool-call"));\n'
+ "console.log(JSON.stringify({ plain, range }));"
)
out = json.loads(_run_node(driver))
plain, range = out["plain"], out["range"]
# The plain line: byte-identical to pre-phase — ONE <code> child, no
# .read-range span, the exact pinned text.
assert plain["text"] == "📄 Reading samedoc/twice.md", plain
assert len(plain["children"]) == 1, plain
assert plain["children"][0] == {"tag": "code", "cls": "", "text": "samedoc/twice.md"}, plain
assert all(c["cls"] != "read-range" for c in plain["children"]), (
"a plain read must carry NO .read-range span"
)
# The range line: the <code> child PLUS the .read-range sibling with
# the exact suffix (10 + 20 - 1 = 29, en-dash).
assert range["text"] == "📄 Reading samedoc/twice.md (lines 10–29)", range
assert range["children"][0] == {"tag": "code", "cls": "", "text": "samedoc/twice.md"}, range
assert range["children"][1] == {
"tag": "span",
"cls": "read-range",
"text": " (lines 10–29)",
}, range
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_shared_page_line_parity_under_node() -> None:
"""The REAL shared.js ``addToolLines`` under node renders the SAME
line as app.js's ``appendToolLine`` for the same record (the
parity contract — the shared page re-renders saved records
pixel-identically to the chat page): identical text and identical
children (the ``code`` + the ``.read-range`` span with the same
suffix). A record WITHOUT the fields renders the plain line on both
pages."""
shared_suffix = _extract(_shared_js(), "readRangeSuffix")
add_lines = _extract(_shared_js(), "addToolLines")
app_suffix = _extract(_js(), "readRangeSuffix")
append = _extract(_js(), "appendToolLine")
driver = (
_DOM_STUB
+ "\n"
+ app_suffix
+ "\n"
+ append
+ '\nconst ARG = "samedoc/twice.md";\n'
# app.js: a range read + a plain read
+ "const wa = makeWrap();\n"
+ 'appendToolLine(wa, "read", ARG, 10, 20);\n'
+ 'const appRange = describe(wa.querySelector(".tool-call"));\n'
+ "const wp = makeWrap();\n"
+ 'appendToolLine(wp, "read", ARG);\n'
+ 'const appPlain = describe(wp.querySelector(".tool-call"));\n'
# shared.js: the SAME record (range + no-fields) through addToolLines
+ shared_suffix.replace("function readRangeSuffix", "function sharedSuffix")
+ "\n"
+ add_lines.replace(
"readRangeSuffix(t.offset, t.limit)", "sharedSuffix(t.offset, t.limit)"
)
+ "const ws = makeWrap();\n"
+ 'addToolLines(ws, [{ name: "read", argument: ARG, offset: 10, limit: 20 }]);\n'
+ 'const sharedRange = describe(ws.querySelector(".tool-call"));\n'
+ "const wn = makeWrap();\n"
+ 'addToolLines(wn, [{ name: "read", argument: ARG }]);\n'
+ 'const sharedPlain = describe(wn.querySelector(".tool-call"));\n'
+ "console.log(JSON.stringify({ appRange, appPlain, sharedRange, sharedPlain }));"
)
out = json.loads(_run_node(driver))
# The range line is pixel-identical across the two pages.
assert out["sharedRange"] == out["appRange"], (
f"parity broken (range): {out['sharedRange']} vs {out['appRange']}"
)
# A record without the fields renders the plain line on both pages.
assert out["sharedPlain"] == out["appPlain"], (
f"parity broken (plain): {out['sharedPlain']} vs {out['appPlain']}"
)
# And the plain line carries no .read-range span (byte-identical).
assert all(c["cls"] != "read-range" for c in out["sharedPlain"]["children"])
+2 -2
View File
@@ -163,7 +163,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
assert fn != -1
end = js.find("function restoreConversation")
body = js[fn:end]
assert "appendToolLine(wrap, t.name, arg)" in body
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body
assert "t.truncated && arg" in body, (
"only an argument-bearing (Reading) record with the flag renders"
" the marker"
@@ -173,7 +173,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
" Number(t.chars_total) || 0)" in body
)
# The marker append sits INSIDE the tools loop, after the line append.
line_idx = body.find("appendToolLine(wrap, t.name, arg)")
line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)")
note_idx = body.find("appendTruncatedNote(wrap, arg,")
assert -1 < line_idx < note_idx
+69 -3
View File
@@ -45,12 +45,16 @@ def _source_ref() -> dict:
def _tool_call() -> dict:
# Phase 95 (task 02): the truncation record rides the same entry —
# the CURRENT full shape (additive fields, defaults for a plain read).
# Phase 139 (task 02): the read's requested range joins the same
# entry (additive — None for a plain read, the phase-95 pattern).
return {
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
@@ -401,12 +405,18 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"tools": [
# Phase 95 (task 02): the current full shape — one
# truncated read (the marker record) + a plain ls.
# Phase 139 (task 02): the truncated read is a RANGE
# read — the requested offset/limit ride the same entry
# so a save → restore re-renders the "(lines 42–71)"
# suffix; the ls carries no range (None, as serialized).
{
"name": "read",
"argument": "Homelab/kubernetes.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
"offset": 42,
"limit": 30,
},
{
"name": "ls",
@@ -414,6 +424,8 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
},
],
"stopped": None,
@@ -484,24 +496,78 @@ def test_realistic_payload_round_trips_through_update_model() -> None:
def test_tool_call_round_trips_with_truncation_fields() -> None:
"""The CURRENT full shape (phase 95 task 02): a truncated read's
record (``truncated: True`` + the two non-negative counts) validates
"""The CURRENT full shape (phase 95 task 02 + phase 139 task 02):
a truncated read's record (``truncated: True`` + the two non-negative
counts + the additive range fields, absent for a plain read) validates
and round-trips ``model_dump()`` unchanged — the save payload carries
it with zero other change (the UI re-renders the marker from it)."""
it with zero other change (the UI re-renders the marker — and, for a
range read, the "(lines N–M)" suffix — from it)."""
raw = {
"name": "read",
"argument": "Homelab/big.md",
"truncated": True,
"chars_shown": 128_000,
"chars_total": 204_000,
"offset": None,
"limit": None,
}
call = ToolCall.model_validate(raw)
assert call.truncated is True
assert call.chars_shown == 128_000
assert call.chars_total == 204_000
assert call.offset is None and call.limit is None
assert call.model_dump() == raw
def test_tool_call_range_fields_round_trip() -> None:
"""Phase 139 (task 02): a RANGE read's record (``offset`` +
``limit``, the phase-131 wire fields) validates and round-trips
``model_dump()`` unchanged — the saved chat's ``tools`` JSONB keeps
the range, so the chat page's restore path and the shared page
re-render the "(lines N–M)" suffix from the stored row. Only ONE of
the two is also a real shape (the model may pass a start without a
count — the UI's "(from line N)" line)."""
both = ToolCall.model_validate(
{"name": "read", "argument": "Homelab/big.md", "offset": 42, "limit": 30}
)
assert (both.offset, both.limit) == (42, 30)
assert both.model_dump() == {
"name": "read",
"argument": "Homelab/big.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": 42,
"limit": 30,
}
offset_only = ToolCall.model_validate(
{"name": "read", "argument": "Homelab/big.md", "offset": 7}
)
assert (offset_only.offset, offset_only.limit) == (7, None)
limit_only = ToolCall.model_validate(
{"name": "read", "argument": "Homelab/big.md", "limit": 20}
)
assert (limit_only.offset, limit_only.limit) == (None, 20)
def test_tool_call_range_fields_reject_zero_or_negative() -> None:
"""Phase 139 (task 02) bounds (the phase-83 philosophy, the UI's
guard mirrored): a line number is >= 1 (the server's
``_whole_line_number`` bound — 0 / negative are not real ranges, and
the UI never legitimately sends them; the bool exclusion lives on
the SSE-side guard, where frames are minted)."""
for bad_offset, bad_limit in ((0, None), (None, 0), (-3, None), (None, -1)):
with pytest.raises(ValidationError):
ToolCall.model_validate(
{
"name": "read",
"argument": "x",
"offset": bad_offset,
"limit": bad_limit,
}
)
def test_tool_call_old_shape_validates_with_defaults() -> None:
"""Backward-compat (the phase-50 rule): a saved chat written BEFORE
phase 95 — tool records without the truncation fields — validates
+2 -2
View File
@@ -136,10 +136,10 @@ def test_restore_path_folds_the_disclosure() -> None:
fn = js.find("function renderStoredMessage")
assert fn != -1
body = js[fn : js.find("function restoreConversation", fn)]
assert "appendToolLine(wrap, t.name, arg)" in body
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body
assert "closeToolCalls(wrap)" in body
assert (
body.find("appendToolLine(wrap, t.name, arg)")
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)")
< body.find("closeToolCalls(wrap)")
), "the fold sits AFTER the tool-line restore loop"