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:
+80
-7
@@ -1116,7 +1116,7 @@ function closeToolCalls(wrap) {
|
||||
* migration. The content marks (the read/search/list glyphs) stay the
|
||||
* exact tool-line template literals — the frontend emoji guard strips
|
||||
* precisely those in this file. */
|
||||
function appendToolLine(wrap, name, argument) {
|
||||
function appendToolLine(wrap, name, argument, offset = null, limit = null) {
|
||||
const body = wrap?.querySelector?.(".msg-body");
|
||||
if (!body) return;
|
||||
// Phase 117 (D2): the lines ride in a native <details> disclosure —
|
||||
@@ -1150,6 +1150,19 @@ function appendToolLine(wrap, name, argument) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the path is data, never markup
|
||||
line.appendChild(code);
|
||||
// Phase 139 (task 01): the requested line range — a span SIBLING
|
||||
// after the <code> child (the phase-95 truncation-note precedent:
|
||||
// append a span, never rewrite the pinned template text), appended
|
||||
// ONLY when the suffix is non-empty — a plain read's line stays
|
||||
// byte-identical to pre-phase (the phase-87/117 pins and the
|
||||
// frontend emoji guard stay green; no new emoji).
|
||||
const rangeSuffix = readRangeSuffix(offset, limit);
|
||||
if (rangeSuffix) {
|
||||
const range = document.createElement("span");
|
||||
range.className = "read-range";
|
||||
range.textContent = rangeSuffix; // the range is data, never markup
|
||||
line.appendChild(range);
|
||||
}
|
||||
} else if ((name === "grep" || name === "search_documents") && argument) {
|
||||
line.textContent = "🔎 Searching for ";
|
||||
const code = document.createElement("code");
|
||||
@@ -1171,6 +1184,30 @@ function appendToolLine(wrap, name, argument) {
|
||||
`Tool call${n === 1 ? "" : "s"} (${n})`;
|
||||
}
|
||||
|
||||
/* Phase 139 (task 01): the requested line range on the Reading line —
|
||||
* the owner's TODO: repeated reads show "the same file over and over"
|
||||
* with no way to tell which lines each read covered. A range read
|
||||
* (read(path, offset, limit), phase 131) renders WHICH lines were
|
||||
* requested, so two reads of one file are distinguishable. The single
|
||||
* source of truth for the wording — the tool line and the status label
|
||||
* both render it (ASSUMPTION A2). The well-formedness guard mirrors the
|
||||
* server's _whole_line_number (app/api/chat.py): Number.isInteger(x) &&
|
||||
* x >= 1 (bools are not integers in JS — the house guard, as on the
|
||||
* server) — a malformed value renders no suffix. The range shown is the
|
||||
* model's REQUESTED extent (ASSUMPTION A1): offset–(offset + limit − 1)
|
||||
* with the en-dash per the pinned copy — the UI cannot know the document
|
||||
* length, so a limit past EOF renders the requested extent. Neither
|
||||
* field well-formed → "" — no span, no bytes added, the plain line stays
|
||||
* byte-identical to pre-phase. */
|
||||
function readRangeSuffix(offset, limit) {
|
||||
const offOk = Number.isInteger(offset) && offset >= 1;
|
||||
const limOk = Number.isInteger(limit) && limit >= 1;
|
||||
if (offOk && limOk) return ` (lines ${offset}–${offset + limit - 1})`;
|
||||
if (offOk) return ` (from line ${offset})`;
|
||||
if (limOk) return ` (first ${limit} lines)`;
|
||||
return "";
|
||||
}
|
||||
|
||||
/* Phase 95 (task 02): the truncation marker on a Reading line. The
|
||||
* `tool_result` frame's argument is the model's raw `source/path` — the
|
||||
* SAME string the matching `tool` frame put in the line's `<code>` child
|
||||
@@ -2035,7 +2072,18 @@ function renderStoredMessage(m) {
|
||||
if (!t || typeof t.name !== "string") continue;
|
||||
const arg =
|
||||
typeof t.argument === "string" && t.argument ? t.argument : null;
|
||||
appendToolLine(wrap, t.name, arg);
|
||||
// Phase 139 (task 02): a saved range read (the live toolAcc
|
||||
// stamp — additive on the bor.chat.v1 record, the phase-95
|
||||
// truncated-fields precedent) re-renders the SAME suffix through
|
||||
// the SAME helper: the same Number.isInteger guard → a record
|
||||
// saved before the phase (no fields) renders byte-identically
|
||||
// (the phase-95 truncation note is untouched — it keys on the
|
||||
// <code> argument, not the range).
|
||||
const tOffset =
|
||||
Number.isInteger(t.offset) && t.offset >= 1 ? t.offset : null;
|
||||
const tLimit =
|
||||
Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null;
|
||||
appendToolLine(wrap, t.name, arg, tOffset, tLimit);
|
||||
if (t.truncated && arg) {
|
||||
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
|
||||
}
|
||||
@@ -2991,7 +3039,9 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
let sawThinking = false; // did any `thinking` frame arrive this turn?
|
||||
let sawDone = false; // did the stream end with a `done` event?
|
||||
let toolAcc = []; // phase 37: {name, argument} per `tool` frame —
|
||||
// persisted with the turn (optional `tools` key)
|
||||
// persisted with the turn (optional `tools` key);
|
||||
// phase 139: a range read's {offset, limit} join
|
||||
// additively (each field only when well-formed)
|
||||
|
||||
try {
|
||||
// thinking = pre-token: dots + busy button. The guard is armed so a
|
||||
@@ -3113,16 +3163,39 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
const name = typeof ev.name === "string" ? ev.name : "";
|
||||
const argument =
|
||||
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
|
||||
toolAcc.push({ name, argument });
|
||||
// Phase 139 (task 02): the phase-131 wire fields — the `tool`
|
||||
// frame already carries offset/limit for a range read (ABSENT
|
||||
// on every non-range frame, never null — the server's
|
||||
// _whole_line_number drops them when malformed). The SAME
|
||||
// well-formedness guard as readRangeSuffix (Number.isInteger(x)
|
||||
// && x >= 1) → null when absent / malformed, and each field
|
||||
// lands on the record ONLY when non-null: a non-range frame
|
||||
// keeps the exact pre-phase {name, argument} shape (the
|
||||
// bor.chat.v1 save JSONB is additive — old records restore
|
||||
// byte-identically, no migration), while a range read persists
|
||||
// WHICH lines the model requested (the owner's "same file over
|
||||
// and over" confusion — two reads of one file now differ).
|
||||
const frameOffset =
|
||||
Number.isInteger(ev.offset) && ev.offset >= 1 ? ev.offset : null;
|
||||
const frameLimit =
|
||||
Number.isInteger(ev.limit) && ev.limit >= 1 ? ev.limit : null;
|
||||
const toolRecord = { name, argument };
|
||||
if (frameOffset !== null) toolRecord.offset = frameOffset;
|
||||
if (frameLimit !== null) toolRecord.limit = frameLimit;
|
||||
toolAcc.push(toolRecord);
|
||||
clearTurnTimeout(); // the stream is alive — a frame arrived
|
||||
if (!wrap) wrap = addMessage("brain", "");
|
||||
// Phase 70: the harness-aligned names (read/grep/ls) map to the
|
||||
// same status copy as their legacy counterparts (read_document /
|
||||
// search_documents) — a pre-remap frame keeps its label; the
|
||||
// scoped ls mirrors the scoped tool line.
|
||||
// scoped ls mirrors the scoped tool line. Phase 139 (task 02,
|
||||
// ASSUMPTION A2): the read status carries the SAME requested-
|
||||
// range suffix as the line (readRangeSuffix — the single source
|
||||
// of truth for the wording; a non-range read renders
|
||||
// byte-identical, the helper returns "").
|
||||
const toolStatus =
|
||||
(name === "read" || name === "read_document") && argument
|
||||
? `${brand()} is reading ${argument}`
|
||||
? `${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`
|
||||
: (name === "grep" || name === "search_documents") && argument
|
||||
? `${brand()} is searching for ${argument}`
|
||||
: name === "ls" && argument
|
||||
@@ -3134,7 +3207,7 @@ async function runTurn(text, { reask = false, image = null } = {}) {
|
||||
.querySelector("#typing-indicator .bubble")
|
||||
?.setAttribute("aria-label", toolStatus);
|
||||
}
|
||||
appendToolLine(wrap, name, argument);
|
||||
appendToolLine(wrap, name, argument, frameOffset, frameLimit);
|
||||
// Phase 87 (TODO.md L5): arm this line's elapsed clock — the
|
||||
// baseline resets per line, so each line counts its OWN
|
||||
// frameless silence. This live branch is the ONLY arm call site
|
||||
|
||||
@@ -203,6 +203,17 @@ function addToolLines(wrap, tools) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = argument; // the path is data, never markup
|
||||
line.appendChild(code);
|
||||
// Phase 139 (task 01): the SAME requested-range suffix as the chat
|
||||
// page's appendToolLine (the parity contract) — a span SIBLING
|
||||
// after the <code> child, appended ONLY when the suffix is
|
||||
// non-empty: a record without the fields renders exactly as today.
|
||||
const rangeSuffix = readRangeSuffix(t.offset, t.limit);
|
||||
if (rangeSuffix) {
|
||||
const range = document.createElement("span");
|
||||
range.className = "read-range";
|
||||
range.textContent = rangeSuffix; // the range is data, never markup
|
||||
line.appendChild(range);
|
||||
}
|
||||
} else if (
|
||||
(t.name === "grep" || t.name === "search_documents") && argument
|
||||
) {
|
||||
@@ -236,6 +247,25 @@ function addToolLines(wrap, tools) {
|
||||
body.insertBefore(disclosure, body.querySelector(".bubble"));
|
||||
}
|
||||
|
||||
/* Phase 139 (task 01): the shared page's parity copy of app.js's
|
||||
* readRangeSuffix — the requested line range on the Reading line. The
|
||||
* saved record now carries the chat page's offset/limit (the toolAcc
|
||||
* stamp, phase 139); a record saved before the phase (no fields) renders
|
||||
* exactly as today (no migration). IDENTICAL literals and IDENTICAL
|
||||
* Number.isInteger guard (mirrors the server's _whole_line_number,
|
||||
* app/api/chat.py) — the shared page re-renders saved records
|
||||
* pixel-identically to the chat page's restore path (the phase-50
|
||||
* contract). The range shown is the model's REQUESTED extent (the
|
||||
* ASSUMPTION A1 pin in app.js's readRangeSuffix). */
|
||||
function readRangeSuffix(offset, limit) {
|
||||
const offOk = Number.isInteger(offset) && offset >= 1;
|
||||
const limOk = Number.isInteger(limit) && limit >= 1;
|
||||
if (offOk && limOk) return ` (lines ${offset}–${offset + limit - 1})`;
|
||||
if (offOk) return ` (from line ${offset})`;
|
||||
if (limOk) return ` (first ${limit} lines)`;
|
||||
return "";
|
||||
}
|
||||
|
||||
/* "Maybe try:" chips under a deflected bubble (honesty gate, phase
|
||||
* 04) — PLAIN SPAN text, not buttons (owner-locked 2026-08-29: a
|
||||
* guest tapping a chip has nowhere to go — zero interactive
|
||||
|
||||
Reference in New Issue
Block a user