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.");
}
+21 -1
View File
@@ -153,7 +153,17 @@ function addThinkingBlock(wrap, thinking) {
* line (no migration). The content marks are the exact app.js
* template strings — the frontend emoji guard (tests/integration/
* test_api.py) strips precisely those literals in this file, as in
* app.js. */
* app.js.
*
* Phase 95 (task 02): the truncation marker rides the SAME stored
* record the chat page uses — a {name, argument, truncated,
* chars_shown, chars_total} entry (the live `tool_result` frame's stamp,
* persisted with the turn) re-renders the identical " (truncated —
* showing N of M chars)" span next to its Reading line, so a shared
* page shows the truncation pixel-identically to the chat page (the
* phase-50 restore contract). A record saved before phase 95 (no
* fields) renders exactly as before (no marker, no migration).
* createElement + textContent only — nothing HTML-shaped from storage. */
function addToolLines(wrap, tools) {
if (!Array.isArray(tools) || !tools.length) return;
const body = wrap.querySelector(".msg-body");
@@ -192,6 +202,16 @@ function addToolLines(wrap, tools) {
line.textContent = "🔎 Listing documents";
}
container.appendChild(line);
// Phase 95: the stored truncation record — the same marker the chat
// page's restore path renders (plain integers, no separators);
// only argument-bearing (Reading) lines can carry it.
if (t.truncated && argument) {
const note = document.createElement("span");
note.className = "truncated-note";
note.textContent =
" (truncated — showing " + (Number(t.chars_shown) || 0) + " of " + (Number(t.chars_total) || 0) + " chars)";
line.appendChild(note);
}
}
body.insertBefore(container, body.querySelector(".bubble"));
}
+10
View File
@@ -644,6 +644,16 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
margin-left: 0.5rem;
white-space: nowrap;
}
/* Phase 95 (task 02): the truncation marker on a Reading line — the
" (truncated — showing N of M chars)" span the `tool_result` frame
appends (live) or the restore/shared paths re-render from the stored
record. Theme-neutral, NO new hue (the phase-92 zero-literal
invariant): it borrows --ink-soft — the same AA-safe soft-ink the
status suffixes use — so under phase 93's monochrome theme it grays
automatically, and the marker stays TEXT, never color alone (B5). */
.tool-call .truncated-note {
color: var(--ink-soft);
}
.msg-meta {
font-size: 0.75rem;