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`
693 lines
28 KiB
Python
693 lines
28 KiB
Python
"""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)
|