phase: 95_read_truncation_cap
Build and Push Containers / build-and-push-app (push) Successful in 1m38s
Build and Push Containers / build-and-push-db (push) Successful in 12s

All gates are green. Final verification is complete — no fixes were needed; the phase is fully implemented and passing.

**Phase 95 final verification report**

- Verified all 3 tasks already implemented (task files in `complete/`): read cap + notice + `ToolResultPiece` (task 01), `tool_result` SSE + live/saved/shared marker (task 02), E2E suite (task 03).
- Code checks passed: `BOR_READ_MAX_CHARS` (default 128 000, validator), pinned `READ_TRUNCATION_NOTICE` + `TRUNCATION_MARKER`, byte-identical at/under-cap read, holder `read_truncations` (no `tool_calls` impact), piece order (after `tool`, before next round), `ChatToolResultEvent`, `ToolCall` back-compat fields, `app.js` live/restore handlers, `shared.js`, `.truncated-note` CSS, `.env.example` entry, mock-LLM echo script.
- **Tests:** `uv run pytest` → 1966 passed; `uv run pytest --cov=app --cov-report=term-missing` → all green, TOTAL **99%** (>90% gate).
- **E2E:** `uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov` → **3 passed** (frame order + live marker + LLM notice via echo; save→shared fidelity; short-read control).
- **Regression (isolated):** `test_agent_document_tools` 4 ✓, `test_chat_history` 5 ✓, `test_share_chat` 4 ✓, `test_big_read_progress` 4 ✓, `test_stop_generation` 3 ✓.
- **Lint/types:** `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.

**Completion criteria:** ① over-cap read → first-cap-chars + marker + pinned notice — ✓ (unit-pinned: at-cap/cap+1/notice tests); ② user marker live/saved/shared — ✓ (E2E + frontend tests); ③ at/under cap byte-identical, no frame — ✓ (unit + control E2E); ④ top-2 `<documents>` retrieval untouched — ✓ (`app/rag/retriever.py` unmodified vs HEAD); ⑤ suite green, >90% coverage, ruff+pyright clean — ✓; ⑥ no completed-phase behavior change — ✓ (all gates green; commit left to harness per pass rules).

- No defects found; no changes made this pass. Next pending phase: none in `todo/` (96 is the next free number).
This commit is contained in:
2026-09-11 03:42:51 -04:00
parent d4943b4822
commit bcaef800c5
36 changed files with 2836 additions and 43 deletions
+79
View File
@@ -80,6 +80,19 @@
* optional `tools: [{name, argument}]` array next to `thinking` and
* restore re-renders the lines (phase 14 convention).
*
* Truncated reads (phase 95, A15 extension — `tool_result` is the
* seventh, optional SSE event type; existing frames untouched, unknown
* types ignored): a `read` longer than BOR_READ_MAX_CHARS streams ONE
* `tool_result` frame after its `tool` frame, and the handler appends
* the " (truncated — showing N of M chars)" marker to that Reading
* line (appendTruncatedNote — DOM append, never a re-render) AND stamps
* the matching toolAcc entry (newest, same argument) with `truncated` /
* `chars_shown` / `chars_total` — the save payload carries the record
* with zero other change, and BOTH restore paths (the phase-14 local
* renderStoredMessage and the shared page's addToolLines) re-render the
* same marker from the stored record (pixel-identical, the phase-50
* restore contract). A non-truncated read streams no frame at all.
*
* LLM retry status (phase 67, TODO.md L3): if the endpoint dies BEFORE
* the first frame of an LLM request lands, the server restarts that
* request (up to BOR_LLM_RETRIES retries, BOR_LLM_RETRY_DELAY seconds
@@ -959,6 +972,34 @@ function appendToolLine(wrap, name, argument) {
container.appendChild(line);
}
/* 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
* (textContent carries data, never markup) — so the newest `.tool-call`
* line whose code child holds that argument is the target (one line per
* call, phase 37/48 — the marker APPENDS a span sibling, it never
* rewrites the line's pinned template text). createElement + textContent
* only — the house "this file never builds HTML" rule (no innerHTML).
* A frame for a line that is no longer in the DOM (New Chat mid-turn)
* is a silent no-op — the persisted record still carries the counts.
* Pinned marker copy (unit + E2E assertion target): " (truncated —
* showing N of M chars)" — plain integers, no thousands separators. */
function appendTruncatedNote(wrap, argument, charsShown, charsTotal) {
const calls = wrap?.querySelector?.(".tool-calls");
if (!calls) return;
const lines = calls.querySelectorAll(".tool-call");
for (let i = lines.length - 1; i >= 0; i -= 1) {
const code = lines[i].querySelector("code");
if (!code || code.textContent !== argument) continue;
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + charsShown + " of " + charsTotal + " chars)";
lines[i].appendChild(note);
return;
}
}
/* ---------- suggestions (shared chip component, phase 05) ----------
*
* One component, two homes: the onboarding row in the empty state and the
@@ -1442,11 +1483,18 @@ function renderStoredMessage(m) {
if (Array.isArray(m.tools)) {
// Phase 37: restore the tool lines in saved order through the SAME
// append helper as the live frames (no HTML from storage, ever).
// Phase 95: a stored truncation record (truncated + the counts, the
// live tool_result frame's stamp) re-renders the SAME marker next to
// its Reading line — old records without the field render
// unchanged (t.truncated falsy → no marker).
for (const t of m.tools) {
if (!t || typeof t.name !== "string") continue;
const arg =
typeof t.argument === "string" && t.argument ? t.argument : null;
appendToolLine(wrap, t.name, arg);
if (t.truncated && arg) {
appendTruncatedNote(wrap, arg, Number(t.chars_shown) || 0, Number(t.chars_total) || 0);
}
}
}
if (m.deflected) {
@@ -2320,6 +2368,37 @@ async function runTurn(text, { reask = false } = {}) {
}, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
} else if (ev.type === "tool_result") {
// Phase 95 (A15 extension, task 02): the truncation the LLM is
// told about is told to the USER. One frame per truncated read,
// always after its `tool` frame and before the next round — the
// Reading line is already on screen. Settle that line's
// elapsed clock like every other frame (the marker is the
// visible feedback now), then append the marker to the NEWEST
// line carrying this argument (appendTruncatedNote — a DOM
// append to the existing line: no new line, no re-render, the
// phase-37/48 tool-line lifecycle is untouched) and stamp the
// matching toolAcc entry so the save payload carries it (the
// `done` save point below needs zero other change). A frame
// whose line/toolAcc entry is gone (New Chat mid-turn) is a
// silent no-op; a non-truncated read never sends one.
settleToolLine();
const argument =
typeof ev.argument === "string" && ev.argument ? ev.argument : null;
const shown = Number(ev.chars_shown) || 0;
const total = Number(ev.chars_total) || 0;
if (argument && ev.truncated) {
appendTruncatedNote(wrap, argument, shown, total);
for (let i = toolAcc.length - 1; i >= 0; i -= 1) {
const t = toolAcc[i];
if (t && t.argument === argument) {
t.truncated = true;
t.chars_shown = shown;
t.chars_total = total;
break;
}
}
}
} else if (ev.type === "error") {
throw new Error(ev.detail || "Something went wrong on my side.");
}