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
+51 -7
View File
@@ -63,7 +63,23 @@ of the answer's ``delta`` frames: ``argument`` is the single string the
model passed — ``read``'s ``path`` (the combined ``source/path``),
``grep``'s ``pattern``, ``ls``'s ``path`` — or null (a non-string
value — a model error the backend refuses — and an omitted argument
both yield null). ``done.sources``, ``query_log.sources`` and the
both yield null).
Tool-result frames (phase 95, ``TODO.md`` L5 — A15 extension, owner
permission 2026-09-10; the event-type list grows from six to SEVEN,
``tool_result`` among them; PLAN.md is being redone by the owner): a
``read`` whose document is longer than ``BOR_READ_MAX_CHARS`` streams,
AFTER the matching ``tool`` frame (the line is already on screen — the
marker lands a beat later, the phase-37/48 tool-line timing is
untouched) and BEFORE the next model round, exactly one optional
``tool_result`` event — ``{"type": "tool_result", "name": "read",
"argument": …, "truncated": true, "chars_shown": N, "chars_total": M}``
— the additive truncation notice the UI turns into the
"(truncated — showing N of M chars)" marker on the Reading line. A
non-truncated read streams NO such frame (one frame = one noteworthy
event), deflected turns never stream one (the agent never runs, A8),
and every pre-existing frame is byte-identical — clients that do not
know the type ignore it. ``done.sources``, ``query_log.sources`` and the
per-turn log line all report the same combined source list (retrieval
docs + the agent's read docs, deduped by ``(source, path)``, order
preserved — a grep adds no source; it is a locator, locked A5), and the
@@ -165,6 +181,7 @@ from app.rag.llm import (
RetryPiece, # phase 67: one LLM request restart (an SSE retry frame)
StreamPiece, # type of the answer pieces streamed by the agent loop
ToolCallPiece, # phase 37: one model-requested tool call
ToolResultPiece, # phase 95: one truncated tool result (SSE frame = task 02)
chat_stream_retried, # phase 67: the retry-before-first-piece primitive
)
from app.rag.overview import load_kb_overview
@@ -179,6 +196,7 @@ from app.schemas import (
ChatRetryEvent,
ChatThinkingEvent,
ChatToolEvent,
ChatToolResultEvent,
SourceRef,
)
@@ -435,7 +453,9 @@ async def chat(
# ``agent_max_rounds=0`` ``run_agent`` is a single
# ``tools=None`` request anyway (the kill switch).
holder = AgentHolder()
answer_stream: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece]
answer_stream: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
]
deflected_filter: ScaffoldingFilter | None = None
if plan.deflected:
# Phase 67: the deflected stream goes through the retry
@@ -474,13 +494,18 @@ async def chat(
scaffold_stripped = 0 # phase 71: sum across the turn's requests
async def _pump(
pieces: AsyncIterator[StreamPiece | ToolCallPiece | RetryPiece],
pieces: AsyncIterator[
StreamPiece | ToolCallPiece | RetryPiece | ToolResultPiece
],
) -> AsyncIterator[str]:
"""One request's piece loop (phase 71 extraction): the
thinking/tool/retry/delta handling shared by the turn's
first pass and — deflected path only — the one bounded
recovery. Behavior-preserving for the first pass (pinned
by the existing integration suite)."""
thinking/tool/retry/tool_result/delta handling shared by
the turn's first pass and — deflected path only — the one
bounded recovery. Behavior-preserving for the first pass
(pinned by the existing integration suite). Phase 95:
the ``ToolResultPiece`` branch emits the additive
``tool_result`` SSE frame (the seventh, optional event
type — the A15 extension)."""
nonlocal thinking_chars, content_chars, retries_used
async for piece in pieces: # StreamPiece | ToolCallPiece | RetryPiece
if isinstance(piece, ToolCallPiece):
@@ -513,6 +538,25 @@ async def chat(
).model_dump()
)
continue
if isinstance(piece, ToolResultPiece):
# Phase 95 (A15 extension, task 02): one optional
# ``tool_result`` frame per truncated ``read`` —
# emitted HERE, right where the agent loop yielded
# the piece: AFTER the matching ``tool`` frame and
# BEFORE the next model round. Additive: a
# non-truncated read yields no piece at all (no
# frame), and the other six event types are
# byte-identical.
yield sse_event(
ChatToolResultEvent(
name=piece.name,
argument=piece.argument,
truncated=piece.truncated,
chars_shown=piece.chars_shown,
chars_total=piece.chars_total,
).model_dump()
)
continue
if piece.kind == "thinking":
thinking_chars += len(piece.text)
if settings.stream_thinking: