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
@@ -1,49 +0,0 @@
# Phase 95 — `read` cap: bounded reads, honest truncation, visible to LLM and user
**Source:** `TODO.md` L5 — "Brain of reese can sometimes feed huge documents into the LLM's context - sometimes too large for the LLM to handle. If the LLM calls read on a huge document there should be a sensible cap on the amount it can read at once. My LLMs all have a minimum cap of 128,000 tokens of context, so spec for that. The LLM should be informed the read was truncated, and should be offered a grep or search or find tool (whichever matches most closely to existing harnesses) to search the document for what it was looking for. There should be a visual indicator that the read was trnucated so the user knows what's going on."
**Story:** n/a (owner TODO item — agent tooling on `37_agent_document_tools` / `70_harness_aligned_tools`; UI on the phase-37 tool lines).
**Context:** `read` today returns the **whole** document (`app/rag/agent.py` `_execute_tool` read branch: `f"Document {doc.source}/{doc.path}:\n{doc.content}"`) — fine for notes, context-obliterating for the huge files the owner describes. The house already has every primitive this phase needs: `TRUNCATION_MARKER` (`[…truncated…]`, `app/rag/retriever.py`), the char-capped one-shot precedent (`BOR_SUMMARY_MAX_CHARS`, `app/rag/summarizer.py`), the piece family (`StreamPiece` / `ToolCallPiece` / `RetryPiece` in `app/rag/llm.py`, consumed by the `app/api/chat.py` `_pump` loop as SSE frames), and `grep` — the exact "search the document" tool the TODO asks for (already harness-aligned, phase 70). The UI renders one `.tool-call` line per executed call ("📄 Reading …", `frontend/assets/app.js` ~L898ff; the shared view renders the same from saved records, `frontend/assets/shared.js` ~L148); saved chats persist the turn's `tools` array (`ToolCall` in `app/schemas.py`, built client-side from the SSE `tool` frames into `toolAcc`, restored by `app.js` ~L1442 and rendered by `shared.js`).
## Objective
A huge `read` can no longer flood the context: the result is capped (default 128 000 chars ≈ 32k tokens — spec'd against the owner's 128k-token minimum context), the LLM is told the read was truncated and pointed at `grep` for the rest, and the user sees a "(truncated — showing N of M chars)" marker on the Reading line — live, in saved chats, and on shared pages.
## Owner-permitted decisions recorded here (PLAN.md is being redone by the owner)
- **A7 scope clarification (owner permission 2026-09-10, TODO.md L5):** A7's "never truncated" contract (the retrieved **top-2 `<documents>`** context — owner: "this should never happen", phase 24) is **unchanged**. The cap applies to the **`read` tool path only**, per the owner's explicit request in the TODO — the two paths are distinct (retrieval seeds vs. agent-requested additions). This reverses the "this should never happen" ruling **for tool reads only**; recorded here because PLAN.md is being redone.
- **A15 extension (same permission):** one new **optional** SSE event type `tool_result` (emitted only for truncated reads) — the event-type list of A15 grows from six to seven; existing frames and clients are untouched (a `tool_result` frame is additive; unknown types are ignored).
- **Tool choice:** the "grep or search or find tool (whichever matches most closely to existing harnesses)" is **`grep`** — it already exists, searches a single document when scoped, and is the phase-70 harness-aligned name. No new tool is added.
## Design (shared by all tasks — the executor reads this, not the chat)
### The cap (task 01)
- **Setting:** `BOR_READ_MAX_CHARS` — `read_max_chars: int = 128_000` in `app/config.py`. Spec rationale (pinned in the docstring): 128 000 chars ≈ **32 000 tokens** at the ~4-chars/token estimate the house already uses (`app/rag/llm.py` embed batching notes ~3 chars/token for code-dense text, 4 for prose) — **a quarter of the 128k-token minimum context** the owner names, leaving ~96k for the system prompt, the top-2 `<documents>`, the tool rounds, and the 32 768-token answer cap (`max_output_tokens`). Char-based (no tokenizer in the repo — the `BOR_SUMMARY_MAX_CHARS` precedent), env-tunable in both directions.
- **The read branch:** `len(content) > cap` → body = `content[:cap]` + `TRUNCATION_MARKER` + the pinned notice line:
`TRUNCATED — this document is {total} characters; only the first {shown} are in your context. The rest is NOT shown. Use grep (pattern) to locate what you need — grep searches the whole document.`
(constant `READ_TRUNCATION_NOTICE` with `{shown}`/`{total}` format fields, next to the other refusal constants). At exactly the cap: no marker (the document fit).
- **Signaling the UI:** `AgentHolder` gains `read_truncations: list[tuple[str, int, int]]` (argument, chars_shown, chars_total); the read branch appends on truncation. `run_agent`, after executing each round's calls, yields one new piece per new entry — `ToolResultPiece` (new, in `app/rag/llm.py` with the piece family): `name`, `argument`, `truncated: bool`, `chars_shown: int`, `chars_total: int`.
### SSE + UI + persistence (task 02)
- **SSE:** `app/api/chat.py` `_pump` gains the branch (mirror the `ToolCallPiece` branch): `ChatToolResultEvent` (new, `app/schemas.py` next to `ChatToolEvent`) → `{"type": "tool_result", "name": "read", "argument": "src/path", "truncated": true, "chars_shown": N, "chars_total": M}`. Emitted **after** the matching `tool` frame (the call is already shown; the marker lands a beat later — the phase-37/48 "calling tool" timing is untouched). The module docstring's SSE contract paragraph + the A15-extension note are updated.
- **Live UI:** `app.js` — on a `tool_result` frame, find the newest `.tool-call` line whose text is the Reading line for that argument and append `<span class="truncated-note"> (truncated — showing {N} of {M} chars)</span>` (createElement + textContent — the house "this file never builds HTML" rule; no innerHTML). `styles.css`: `.tool-call .truncated-note { color: var(--ink_soft); }` — theme-neutral, **no new hue** (phase-92 invariant; it must gray out automatically under a monochrome theme — see phase 93).
- **Saved + shared:** `ToolCall` (`app/schemas.py`) gains `truncated: bool = False`, `chars_shown: int | None = None (ge=0)`, `chars_total: int | None = None (ge=0)` (phase-83 bounds philosophy: small additive fields, no migration — saved JSON validates). `app.js` `toolAcc`: the `tool_result` frame stamps the matching entry. The save payload carries it; the restore path (`app.js` ~L1442) and `shared.js` render the same marker from the stored record, so a saved/shared chat shows the truncation **pixel-identically** (the phase-50 restore contract).
### E2E (task 03)
`tests/e2e/test_read_truncation_cap.py`: the app under test boots with `BOR_READ_MAX_CHARS=1500` (the `test_import_extensions_env.py` env-override pattern); a local-dir source seeds one ~3 000-char document; a scripted mock-LLM turn `read`s it; asserts — the SSE stream carries the `tool_result` frame with the right counts; the Reading line shows the marker; the mock's echo proves the LLM context carried `[…truncated…]` + the grep pointer; the saved chat's `tools` record carries `truncated: true` + counts; the shared page renders the same marker.
## Tasks
1. `01_read_cap.md` — `BOR_READ_MAX_CHARS` + the truncated read result + `ToolResultPiece` + tool description + prompt teaching
2. `02_sse_and_ui.md` — the `tool_result` SSE event + the live/saved/shared "(truncated …)" marker
3. `03_e2e_truncated_read.md` — the dedicated story suite
## Testing & Quality
- Unit/integration: cap boundary (at cap / cap+1); the marker + notice text pinned; a non-truncated read byte-identical to today's result; `ToolResultPiece` emission order (after the tool frame, before the next round); `holder` accounting (truncations don't touch `tool_calls` — a truncated read is still a successful call); the SSE frame in the chat integration suite; the `ToolCall` schema round-trip (old saved chats without the fields still validate — the phase-50 backward-compat rule); `read`-already-in-context / refusal paths untouched.
- Coverage: **>90%** on new/modified code (`uv run pytest --cov=app --cov-report=term-missing`).
- E2E: `uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov` in isolation; the existing suites (`test_agent_document_tools.py`, `test_chat_history.py`, `test_share_chat.py`, `test_big_read_progress.py`) stay green in isolation.
- Lint/types: `uv run ruff check . && uv run pyright`.
## Completion Criteria
- [ ] a document longer than `BOR_READ_MAX_CHARS` returns first-cap-chars + `[…truncated…]` + the pinned grep-pointer notice to the LLM (unit-pinned)
- [ ] the user sees "(truncated — showing N of M chars)" on the Reading line — live, in a saved chat, and on the shared page
- [ ] a document at/under the cap is read byte-identically to today (no marker, no frame)
- [ ] the top-2 `<documents>` retrieval path is untouched (A7's never-truncated contract holds for it)
- [ ] test suite green, coverage >90%, ruff + pyright clean
- [ ] no behavior change in completed phases; one atomic Conventional Commit, `--no-gpg-sign` (e.g. `feat(agent): cap read at 128k chars with honest truncation and a visible UI marker`)
@@ -1,37 +0,0 @@
# Task 01 — The read cap: setting, truncated result, the piece, the teaching
**Phase:** `95_read_truncation_cap` · **Source:** `TODO.md:5` — "If the LLM calls read on a huge document there should be a sensible cap on the amount it can read at once. My LLMs all have a minimum cap of 128,000 tokens of context, so spec for that. The LLM should be informed the read was truncated, and should be offered a grep or search or find tool (whichever matches most closely to existing harnesses) to search the document for what it was looking for."
**Story:** n/a (owner TODO item).
## Objective
`read` returns at most `BOR_READ_MAX_CHARS` characters (default 128 000 ≈ 32k tokens, spec'd in the docstring against the 128k-token minimum context); an over-cap read carries `[…truncated…]` + the pinned grep-pointer notice; the truncation is signaled to the API layer via a new `ToolResultPiece`; the tool description and prompt teach the contract.
## Work
1. `app/config.py` — `read_max_chars: int = 128_000` (env `BOR_READ_MAX_CHARS`), docstring with the pinned spec rationale: 128 000 chars ≈ 32 000 tokens at the ~4-chars/token house estimate (`app/rag/llm.py` embed-batching note: ~3 chars/token code-dense, 4 prose) — a quarter of the 128k-token minimum context, leaving ~96k for the system prompt + the top-2 `<documents>` + tool rounds + the 32 768-token answer cap. Char-based per the `BOR_SUMMARY_MAX_CHARS` precedent (no tokenizer in the repo).
2. `app/rag/agent.py`:
- `READ_TRUNCATION_NOTICE` constant (next to the other refusal constants) with `{shown}`/`{total}` format fields, pinned copy:
`TRUNCATED — this document is {total} characters; only the first {shown} are in your context. The rest is NOT shown. Use grep (pattern) to locate what you need — grep searches the whole document.`
- The read branch of `_execute_tool`: when `len(doc.content) > settings.read_max_chars`, the result body is `content[:cap]` + `\n` + `TRUNCATION_MARKER` (`app/rag/retriever.py` — import it, don't retype) + `\n` + the formatted notice; otherwise the result is **byte-identical to today** (`f"Document {doc.source}/{doc.path}:\n{doc.content}"`). On truncation, append `(argument, cap, total)` to the holder (see step 3).
- `AgentHolder` (the dataclass at ~L680): new field `read_truncations: list[tuple[str, int, int]] = field(default_factory=list)` — docstring: recorded per truncated `read`; a truncated read is still a **successful** call (`tool_calls` increments as today, `read_docs` appends as today).
- `run_agent`: after executing a round's calls (where `_execute_tool` results are appended to the messages), yield one `ToolResultPiece` per NEW `holder.read_truncations` entry (snapshot `len` before the round's executions; iterate the new tail) — the piece lands AFTER the round's `tool` frame(s) and BEFORE the next model round.
3. `app/rag/llm.py` — `ToolResultPiece` (with the piece family, next to `ToolCallPiece`): fields `name: str`, `argument: str | None`, `truncated: bool`, `chars_shown: int`, `chars_total: int`; docstring citing the A15 extension (owner permission 2026-09-10, `TODO.md` L5 — recorded in `00_phase.md`; PLAN.md is being redone by the owner).
4. `app/rag/agent.py` — `AGENT_TOOLS` `read` description: append the truncation sentence (pinned copy): "Very large documents are truncated: you receive the first part plus a TRUNCATED notice naming how many more characters exist — the notice is authoritative, the document did NOT end where it stopped. Follow it and use `grep` (pattern) to locate the rest — it searches the whole document."
5. `app/rag/prompts.py` — `TOOLS_SECTION`: one added line to the `read` teaching (short, the house tone): the cap + the notice + the grep follow-up. `ls`/`grep` teaching untouched (phase 94 owns `ls`).
6. `app/rag/agent.py` module docstring — the tool-surface paragraph: the phase-95 revision note (the A7 scope clarification: retrieval top-2 stays never-truncated; the read-tool path is capped per the owner's explicit request).
- ASSUMPTION: 128 000 chars default (≈ 32k tokens), env `BOR_READ_MAX_CHARS` — the TODO says "sensible cap … spec for that"; the quarter-budget of the 128k minimum is the spec (confirmed in the roadmap).
- ASSUMPTION: `grep` is the offered tool (it already exists and is harness-aligned — no new tool); the notice names it explicitly so the model doesn't guess.
- ASSUMPTION: the marker goes AFTER the body content (the model reads the first part cleanly, then the notice) — matching how the summarizer appends `TRUNCATION_MARKER` at its cut.
## Testing & Quality
- Unit (`tests/unit/test_agent.py`): boundary — a document of exactly `cap` chars → no marker, byte-identical to today's result; `cap + 1` → marker + notice with the right `{total}`/`{shown}`; the notice text pinned; `holder.read_truncations` content; `tool_calls`/`read_docs` accounting unchanged for a truncated read; `run_agent` yields the `ToolResultPiece` after the tool frame and before the next round (fake LLM stream), exactly one per truncated read, zero for a short read; the `read` refusal paths (`ALREADY_IN_CONTEXT`, `_no_document_refusal`) untouched.
- Unit: `AGENT_TOOLS` read description + `TOOLS_SECTION` pin the new copy (house string tests).
- Integration: `tests/integration/test_chat_api.py` — the `ToolResultPiece` flows through (the SSE half is task 02; here the agent loop's yield order against the real prompt path).
- Coverage: **>90%** on this task's new/modified code (full gate: `app/`).
## Completion Criteria
- [ ] `read` of a > 128 000-char document returns first 128 000 chars + `[…truncated…]` + the pinned notice (unit-pinned)
- [ ] a ≤ cap document is read byte-identically to today
- [ ] `ToolResultPiece` exists, is yielded in order, and carries (argument, shown, total)
- [ ] full test suite green, coverage >90%
- [ ] no behavior change in completed work (the retrieval `<documents>` path and the deflected path byte-identical)
@@ -1,35 +0,0 @@
# Task 02 — The `tool_result` SSE event + the visible marker (live, saved, shared)
**Phase:** `95_read_truncation_cap` · **Source:** `TODO.md:5` — "There should be a visual indicator that the read was trnucated so the user knows what's going on." (plus the A15 extension + saved/shared persistence)
**Story:** n/a (owner TODO item).
## Objective
The truncation the LLM is told about is also told to the USER: a `tool_result` SSE frame (emitted only for truncated reads) and a "(truncated — showing N of M chars)" marker on the Reading line — live during the stream, in saved chats, and on shared pages.
## Work
1. `app/schemas.py` — `ChatToolResultEvent` next to `ChatToolEvent`: `type: str = "tool_result"`, `name: str`, `argument: str | None`, `truncated: bool = True`, `chars_shown: int (ge=0)`, `chars_total: int (ge=0)`; docstring citing the A15 extension (owner permission 2026-09-10 — the event-type list grows six → seven; additive, existing frames untouched).
2. `app/api/chat.py` — the `_pump` piece loop: a new `isinstance(piece, ToolResultPiece)` branch (mirror the `ToolCallPiece` branch) → `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())`. Update the module docstring's SSE contract paragraph (the event list + the "emitted after the matching `tool` frame" timing + the A15-extension note).
3. `frontend/assets/app.js`:
- the SSE `tool_result` handler: find the NEWEST `.tool-call` line in the turn's scratchpad whose text is the Reading line for the frame's argument (the `📄 Reading {argument}` line the `tool` frame created) and append a marker: `document.createElement("span")` + `className = "truncated-note"` + `textContent = " (truncated — showing " + N + " of " + M + " chars)"` (the house "this file never builds HTML" rule — createElement/textContent only, never innerHTML).
- `toolAcc` (the saved-session tools array, built from the `tool` frames): on the `tool_result` frame, stamp the matching entry (same argument, newest) with `truncated: true` + `chars_shown` + `chars_total` — the save payload then carries it with zero other change.
- the phase-14 LOCAL restore path (~L1442, `for (const t of m.tools)`): a stored `t.truncated` renders the same marker next to the restored Reading line.
4. `frontend/assets/shared.js` — the shared view's tool-line render (~L148, the "read → the Reading line" mapping): the same marker from the stored record.
5. `frontend/assets/styles.css` — `.tool-call .truncated-note { color: var(--ink_soft); }` — theme-neutral, no new hue (the phase-92 zero-literal invariant; under phase 93's monochrome theme it grays automatically).
6. `app/schemas.py` — `ToolCall` (the saved-session record, ~L443): add `truncated: bool = False`, `chars_shown: int | None = Field(default=None, ge=0)`, `chars_total: int | None = Field(default=None, ge=0)`; docstring note — pre-phase-95 saved chats (no fields) validate unchanged (the phase-50 backward-compat rule; no migration — `ChatMessage.tools` is JSON).
- ASSUMPTION: marker copy pinned exactly as ` (truncated — showing {N} of {M} chars)` — plain integers, no thousands separators (the assertion target for unit + E2E).
- ASSUMPTION: the marker is a CSSOM/DOM append to the existing line (no new line, no re-render) — the phase-37/48 tool-line lifecycle (one line per call, "Stop" label contract) is untouched.
- ASSUMPTION: `tool_result` frames for non-truncated reads are NOT emitted (one frame = one noteworthy event; the live marker only appears when truncation actually happened).
## Testing & Quality
- Integration (`tests/integration/test_chat_api.py`): a grounded turn with a truncated read (test DB + a long document + the cap lowered via settings override) streams `tool` → `tool_result` (asserted in order, right counts) → `delta…`; a non-truncated read streams NO `tool_result`; the deflected path streams no tool frames at all (A8 unchanged).
- Unit (house frontend-text style): `app.js` contains the `tool_result` handler + the exact marker template + the `toolAcc` stamp; `shared.js` the same render; `styles.css` the `.truncated-note` rule uses only a `var(--…)` color.
- Unit (`tests/unit/` schemas): `ToolCall` round-trips with and without the new fields (old-shape JSON validates — the backward-compat assertion); `ChatToolResultEvent` shape.
- Coverage: **>90%** on this task's new/modified code (full gate: `app/`).
## Completion Criteria
- [ ] the SSE stream of a truncated read carries exactly one `tool_result` frame with the right counts, after its `tool` frame
- [ ] the Reading line shows the marker live; a saved chat re-renders it; the shared page shows it
- [ ] old saved chats (no `truncated` field) load and render unchanged
- [ ] full test suite green, coverage >90%
- [ ] no behavior change in completed work (the six existing event types byte-identical)
@@ -1,33 +0,0 @@
# Task 03 — E2E: the truncated read (the dedicated story suite)
**Phase:** `95_read_truncation_cap` · **Source:** `TODO.md:5` — the whole item (this task is the story gate: the LLM is told about the truncation, the user sees the marker — live, saved, shared)
**Story:** n/a (owner TODO item — one Playwright file per story, run in isolation, A16).
## Objective
`tests/e2e/test_read_truncation_cap.py` proves the contract end to end under the deterministic mock LLM: a document over the (lowered) cap is read truncated, the `tool_result` frame lands, the Reading line carries the marker, the LLM's context carried `[…truncated…]` + the grep pointer, and the marker survives save → shared.
## Work
1. `tests/e2e/test_read_truncation_cap.py` (new, isolated — `tests/e2e/conftest.py` fixtures `app_server` + `mock_llm`; admin login via `tests/e2e/auth_helpers.py`):
- **Lowered cap:** the app under test boots with `BOR_READ_MAX_CHARS=1500` (the env-override-for-the-app-under-test pattern from `tests/e2e/test_import_extensions_env.py`).
- **Seed:** one local-directory source containing one ~3 000-character `.md` document (deterministic content — e.g. a repeated but varied paragraph block; the exact text is the executor's, pinned in the test so the counts are known), registered + synced (the `tests/e2e/test_local_directory_sources.py` pattern).
- **Scripted turn** (extend `tests/e2e/mock_llm.py` with a new script, the `tests/e2e/test_agent_document_tools.py` pattern): the mock calls `read("<source>/<file>.md")` and then answers by **echoing** what its tool result contained (the house way of asserting on the LLM's context).
- **Assertions:**
1. the SSE stream (captured over the websocket/SSE read the chat suites use) carries `{"type": "tool", "name": "read", …}` THEN exactly one `{"type": "tool_result", "name": "read", "truncated": true, "chars_shown": 1500, "chars_total": <known>}` THEN the answer's `delta`/`done` frames;
2. the Reading line in the scratchpad shows ` (truncated — showing 1500 of <known> chars)` (the pinned copy, task 02);
3. the mock's echoed answer proves the LLM context carried `[…truncated…]` AND the `TRUNCATED — … Use grep …` notice (assert both substrings in the rendered answer);
4. **save → shared:** save the chat (the phase-50 save flow) → the stored message's `tools` record has `truncated: true` + the counts (assert via the saved-chats API) → open the shared page (`/shared/<token>`) → the Reading line there shows the same marker;
5. **control:** a second turn reading a SHORT document (under 1500 chars) → NO `tool_result` frame, NO marker, the answer's echo shows no `[…truncated…]`.
2. Run in isolation: `uv run pytest tests/e2e/test_read_truncation_cap.py -v --no-cov` (db up: `podman compose up -d db`).
3. Regression gate, in isolation: `test_agent_document_tools.py`, `test_chat_history.py`, `test_share_chat.py`, `test_big_read_progress.py`, `test_stop_generation.py`.
- ASSUMPTION: the SSE capture reuses whatever stream-reading helper the existing chat suites use (the `test_chat_rag.py` / `test_stop_generation.py` pattern) — no new harness machinery.
- ASSUMPTION: `chars_total` is asserted against the pinned seed document's exact length (the test computes `len(content)` from the same string it writes).
## Testing & Quality
- This IS the E2E task.
- Coverage: **>90%** on `app/` (this task adds test code only — keep the gate green).
## Completion Criteria
- [ ] the suite is green in isolation and encodes: frame order, live marker, LLM-visible notice (via the echo), save + shared fidelity, and the no-truncation control
- [ ] full test suite green, coverage >90%
- [ ] no behavior change in completed work (the five gated suites green)