"""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 source/path (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=`` 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 ```` section is present, so both scripted flows fire); lines 2โ€“300 are fixed-length filler (``Line : โ€ฆ`` โ€” 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 ```` 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=`` 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 : โ€ฆ``) โ€” 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 ```` 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 ```` 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 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 # 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= # -------------------------------------------------------------------------- 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= 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)