phase: 139_read_range_tool_lines
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:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, (
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user