chore(agent): phase roadmap from TODO.md, 5 phases

Protocol B append (phases 141-145) for the five TODO items: the
read-range dedupe false "Already in context" bug (141), the stale
KB overview recency ordering + honest intro (142), the mobile
keyboard pop after answer completion (143), the document modal
back-button history entry (144), and the git original-source links
in answers + the doc modal (145). TODO.md cleared (items now live
in .agents/phases/todo/).

LLM-Generated: true
This commit is contained in:
2026-09-30 21:52:27 -04:00
parent 9633eb7653
commit 281684dc8e
23 changed files with 755 additions and 6 deletions
@@ -0,0 +1,51 @@
# Phase 141 — `read` dedupe tells the truth: only fully-covered re-reads are refused
**Source:** `TODO.md` L3 — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `app/rag/agent.py` — `AgentHolder` (L1379+: `read_docs` / `read_truncations` / `shown_images` bookkeeping), `_execute_tool`'s `read` branch (L1512+: `_read_range_args` validation ~L1523, the dedupe `known` set ~L1539, the `truncated_shown` map ~L1548, the raw-arg fast path ~L1557, the range-read dedupe check ~L1577, the `read_docs` append ~L1600, the result shapes ~L1630+), the refusal texts `ALREADY_IN_CONTEXT` (L519), `REREAD_TRUNCATED` (L600), `OFFSET_PAST_END` (L616); `tests/unit/test_agent.py` (the read-branch unit suite — the phase-131 dedupe-matrix pins); `tests/e2e/mock_llm.py` (`_READ_RESULT_PREFIX` L1412 — the first-line header contract the mock parses), `tests/e2e/test_read_line_ranges.py` (the phase-131 flow). Root cause: `known` (the `read_docs` pair set) records ANY successful read — including a RANGE read — as if the FULL document were in context; the phase-131 matrix's "range read + doc in `known` + not truncated → `ALREADY_IN_CONTEXT`" row is false whenever the earlier read was a range (only the slice reached the model), so every attempt to read a DIFFERENT range is refused with a claim that the full text is in the prompt.
**Locked (owner-confirmed 2026-09-30, chat):** coverage-tracking dedupe — a range read proceeds unless the requested lines are FULLY covered by earlier reads; a plain re-read of a partially-covered document gets the "you have lines a–b — read the rest with `offset`/`limit`" teaching instead of the false `ALREADY_IN_CONTEXT`.
## Objective
The agent stops dead-ending on multi-range documents: the holder tracks exactly WHICH line ranges of each document are in the model's context, the dedupe matrix refuses only genuinely-covered re-reads (now with a truthful claim), and the owner's stuck scenario — grep → read range A → read range B of the same document — completes with both ranges in context.
## Dependencies
- `140_pictorial_pdf_pages` (complete) — pipeline order only; code dependencies are the completed agent surface (phases 37/70/72/95/118/131/139).
## Design (shared by all tasks — the executor reads this, not the chat)
- **Coverage state (task 01):** `AgentHolder` gains `read_coverage: list[tuple[str, str, int, int]]` — one `(source, path, first_line, last_line)` record per covered span, in read order, keyed by the RESOLVED pair (the phase-131 `truncated_shown` keying lesson: the raw-arg split and the resolved pair are the same pair by the `_resolve_path` first-slash contract, L1229+). Coverage recording:
- a plain read at-or-under-cap covers `(1, total_lines)`;
- a truncated plain read (char cap) covers `(1, the number of lines in content[:cap])` (counted from the actual cut, not derived from the char count);
- a range read covers `(first, end)` — the TRUE extent the `lines: {first}-{end} of {total}` header already reports (an overshooting `limit` is clamped); a truncated range read covers `(first, the last line inside slice[:cap])` the same way.
`read_docs` / `read_truncations` keep their EXACT current meaning (API-layer signal, SSE matching, per-turn log) — the coverage list is NEW state beside them, not a replacement.
- **Dedupe matrix (task 02)** — `covered(a, b)` = the requested span is contained in the union of that document's coverage records (merge overlapping/adjacent spans first; containment is per-line, inclusive):
| call | coverage of the doc | result |
|---|---|---|
| plain `read(path)` | empty | full read (unchanged) |
| plain `read(path)` | full doc covered (1–total) | `ALREADY_IN_CONTEXT` (byte-identical line) |
| plain `read(path)` | partial | NEW/retargeted teaching: the covered span(s) named, "read the rest with `offset`/`limit`" (the `REREAD_TRUNCATED` role — generalized: `REREAD_TRUNCATED` is RETIRED as the partial-plain-read line, its char-based copy replaced by the line-based one) |
| `read(path, offset, limit)` | empty | range read (unchanged) |
| `read(path, offset, limit)` | requested span fully covered | `ALREADY_IN_CONTEXT` — now TRUE (the named lines ARE in the prompt) |
| `read(path, offset, limit)` | not fully covered | range read PROCEEDS (the owner's stuck-scenario fix — includes the phase-131 truncated-then-range row, which the coverage state now covers: a truncated first read covers only its shown lines) |
The `BAD_RANGE_ARGS` and `OFFSET_PAST_END` refusals keep their exact firing order (before dedupe / after resolve respectively). The raw-arg fast path (no DB lookup) survives for the rows answerable from holder state alone (full-coverage plain re-read, fully-covered range — the `total_lines` for the latter's `OFFSET_PAST_END` still needs the resolve).
- **Prompt consistency (task 02, step 4):** `TOOLS_SECTION`'s re-read sentence ("Do not re-read a document you have already read — its full text is already in your prompt") is re-cut to the coverage-true form (read-IN-FULL docs: don't re-read; PARTIAL docs — range/truncated reads: read the other parts with `offset`/`limit`). Without this the model is still taught the pre-phase rule and would not bother trying the second range that task 02 now allows.
- **Repro (task 03):** unit matrix (every row above, incl. truncated variants and span-merge edges) + an integration test of the owner's scenario verbatim in spirit: one document, grep → read range A → read range B → both ranges' content reach the model, then a third read of range A is refused with `ALREADY_IN_CONTEXT`.
- **E2E (task 04):** `tests/e2e/mock_llm.py` gains ONE scripted flow (house style — user-message marker, module-header docstring entry): the marker `read two ranges` → request 1 a `read(path, offset, limit)` call, request 2 a SECOND `read` with a DIFFERENT offset on the same doc, request 3 the answer echoing a marker line from the second range (proving the second slice reached the model). `tests/e2e/test_read_range_dedupe.py` (dedicated, in isolation, mock-only): a KB fixture with ONE large doc carrying two distinct marker lines far apart; ask the marker question → assert both `read` tool frames succeeded (no refusal in the result surface) and the final answer echoes the second marker line.
## Tasks
1. `01_coverage_tracking.md` — `AgentHolder.read_coverage` + the coverage recording in the read branch.
2. `02_dedupe_matrix_rework.md` — the new matrix, the generalized partial-read teaching, `REREAD_TRUNCATED` retirement.
3. `03_two_range_repro.md` — the full unit matrix + the owner-scenario integration repro.
4. `04_e2e_mock_flow.md` — the mock `read two ranges` flow + the dedicated E2E file.
## Testing & Quality
- Unit: the full 6-row matrix with truncated variants; span-merge edges (adjacent, overlapping, contained, disjoint spans); coverage recording for all four read outcomes; the refusal-text pins; the fast-path no-DB rows.
- Integration: the two-range repro (task 03) — the owner's stuck scenario end-to-end through `run_agent`'s loop.
- E2E: one dedicated Playwright file, run in isolation, mock-only.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] two DIFFERENT ranges of the same document both reach the model (the owner's bug is gone); a re-read of fully-covered lines is still refused — with a claim that is now true
- [ ] a plain re-read of a partially-covered document gets the "read the rest with `offset`/`limit`" teaching (the false full-text claim is gone in BOTH refusal directions)
- [ ] plain-read results at-or-under-cap and the phase-131 result shapes are byte-identical (the existing pins pass unchanged)
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the grep/ls surfaces, the truncation notice, the `read_docs`/`read_truncations` API signals)
@@ -0,0 +1,27 @@
# Task 01 — coverage tracking: which lines of each document are actually in the model's context
**Phase:** `141_read_range_dedupe_fix` · **Source:** `TODO.md:3` — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in your context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a
## Objective
`AgentHolder` records, per document, exactly which line spans a successful `read` put in the model's context — the state the reworked dedupe matrix (task 02) decides on. No dedupe behavior changes in THIS task (task 02 does that); this task only adds the recording, so the suite stays green throughout.
## Work
1. `app/rag/agent.py` — `AgentHolder` (L1379+): add `read_coverage: list[tuple[str, str, int, int]]` = one `(source, path, first_line, last_line)` record per covered span, in read order, keyed by the RESOLVED pair (the `_resolve_path` first-slash contract, L1229+ — the same pair `read_docs` keys on; the phase-131 `truncated_shown` keying comment ~L1546 is the precedent for why the raw-arg split and the resolved pair agree). Document the field on the dataclass docstring (the `read_truncations` doc-block style, L1386+): spans are inclusive line numbers over `content.splitlines()`; one span per SUCCESSFUL read (a refused read records nothing — the refusal style of every refusal: counts in nothing).
2. `app/rag/agent.py` — the read branch's four successful outcomes record their span (the `total_lines`/`end`/`first` locals already exist at the record sites, ~L1610+):
- plain read at-or-under-cap (~L1655): `(1, total_lines)`;
- truncated plain read (~L1636): `(1, len(doc.content[:cap].splitlines()))` — counted from the ACTUAL cut string (the last visible line is the one ending at or before `cap`; `splitlines()` on the cut is the truth — do not derive a line count from the char count);
- range read at-or-under-cap (~L1686): `(first, end)` — the TRUE extent the `lines: {first}-{end} of {total}` header reports (an overshooting `limit` is already clamped at ~L1678);
- truncated range read (~L1668): `(first, the last line fully inside slice[:cap])` — `len(slice_text[:cap].splitlines())` added to `first - 1`, clamped to `end` (a cut mid-line means that line is NOT fully in context — the span ends at the last COMPLETE line).
3. `tests/unit/test_agent.py` — unit tests: each of the four outcomes records exactly its span (assert the holder list after the call); a refused call (bad range args, unknown doc, offset past end, already-covered) records NOTHING; spans are keyed by the resolved pair (a doc read through its canonical `source/path` argument); existing `read_docs` / `read_truncations` assertions pass UNCHANGED (those signals are byte-identical).
- ASSUMPTION: the covered-line count for a truncated read is computed from the cut string's `splitlines()` (inclusive, whole lines only) — a trailing cut line that ends exactly at `cap` counts as covered; the char cap is never re-derived into lines arithmetically. (The TODO item left the counting rule open; whole-line containment is what makes "fully covered" checkable per line.)
## Testing & Quality
- Unit: the four record sites + the five no-record refusals + resolved-pair keying (above); every pre-existing read-branch test passes unchanged.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] every successful read shape appends exactly one coverage span with the lines documented above; refusals append nothing
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the holder's existing fields, the result strings, the SSE frames)
@@ -0,0 +1,40 @@
# Task 02 — the dedupe matrix rework: refuse only what is truly already in context
**Phase:** `141_read_range_dedupe_fix` · **Source:** `TODO.md:3` — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a
## Objective
The read branch decides dedupe from the coverage state (task 01) instead of the pair set alone: a range read proceeds unless its lines are fully covered, a plain re-read of a partially-covered document is taught the "read the rest" path, and `ALREADY_IN_CONTEXT` fires only when its claim is true.
## Work
1. `app/rag/agent.py` — a small coverage helper beside `_read_range_args` (L1490+): `covered_spans(records, source, path)` → the merged span list for one doc (merge overlapping/ADJACENT spans — `(1,10)+(11,20)` → `(1,20)` — so adjacency counts as continuous coverage), and `fully_covered(spans, first, last)` → bool (the requested span inside the merged union, per-line, inclusive). Pure functions over holder state — no DB.
2. `app/rag/agent.py` — rework the read branch's dedupe (L1539+), keeping the firing ORDER of the other refusals:
- the `BAD_RANGE_ARGS` check stays FIRST (before any dedupe — a bad range arg is refused regardless of context, phase-131 contract);
- the raw-arg fast path is RETIRED: whether a span is "full" needs the document's `total_lines`, which only the resolve knows — so EVERY dedupe row resolves the doc first (one cheap indexed lookup; the pre-phase fast path's only saving was that one query, and the owner's bug — a false refusal costs a whole stuck turn — is what forces the trade). After the resolve: `total_lines = len(doc.content.splitlines())`, then the matrix below runs against `(doc.source, doc.path, total_lines)` and the coverage state. A fully-covered PLAIN re-read — the only row the old fast path handled — still refuses with the byte-identical `ALREADY_IN_CONTEXT`; its implementation just goes through the resolve.
- matrix (the phase-131 table in 00_phase.md is authoritative):
| call | coverage | result |
|---|---|---|
| plain | empty | full read (the existing at-or-under-cap / truncated paths, untouched bytes) |
| plain | full doc (a merged span == `(1, total_lines)`) | `ALREADY_IN_CONTEXT` (byte-identical line, L519) |
| plain | partial | the NEW teaching line (below) |
| range | empty | range read (existing bytes) |
| range | requested `(first, end)` fully covered | `ALREADY_IN_CONTEXT` |
| range | not fully covered | range read PROCEEDS (the owner's fix) |
- the `OFFSET_PAST_END` refusal (L616, firing after the resolve, before the matrix's range rows) stays where it is — an offset past the end is refused regardless of coverage.
3. `app/rag/agent.py` — the partial-plain-read teaching: RETIRE `REREAD_TRUNCATED` (L600 — its char-based `{shown}` copy is replaced) and add `REREAD_PARTIAL`: one line, same refusal contract (counts in nothing, consumes a round, no re-emit), the covered span(s) named in line form and the working workflow pointed at: "You have lines {covered} of {source}/{path} in your context ({total} lines total). To read the rest, find where your text is with grep (it reports line numbers), then call read with `offset` (1-based first line) and `limit` (number of lines)." — `{covered}` renders the merged spans as comma-joined `a–b` pairs (one span → the bare `a–b`; a single line → `a`). The phase-131 truncated-then-plain-read scenario lands on THIS line now (a truncated first read covers a partial span) — same teaching role, line-based truth.
4. `app/rag/prompts.py` — `TOOLS_SECTION`'s re-read sentence stays consistent with the new semantics: "Do not re-read a document you have already read — its full text is already in your prompt; answer directly from it." becomes the coverage-true form: do not re-read a document you have already read IN FULL — its full text is in the prompt, answer from it — but if you have read only PART of a document (a range read, or a truncated read), read the other parts with `offset`/`limit` — the document is not complete in your context until you have read it all. The sentence stays in its current position (the `read` clause); the mock keys off the `<tools>` marker's PRESENCE, not the wording (the phase-37 contract), so the mock is untouched. The HIGH prompt's byte-identity pins that assert the OLD sentence are re-pinned to the new one (the section's other bytes are unchanged).
5. `tests/unit/test_agent.py` — re-pin the dedupe matrix (the phase-131 pins that asserted the OLD matrix's "range + known + not truncated → ALREADY_IN_CONTEXT" row are the ones that change — every other pin, incl. byte-identical result strings for plain reads, `BAD_RANGE_ARGS`, `OFFSET_PAST_END`, `MISSING_READ_ARGS`, the no-document refusals, passes unchanged):
- the 6 matrix rows × {truncated, non-truncated first read} where defined;
- the partial-plain-read line: span rendering (one span, multiple merged spans, a single line), `{total}` from the real doc;
- span-merge edges: adjacent spans merge (coverage contiguous), overlapping spans merge, disjoint spans stay separate (a gap → the requested span spanning the gap is NOT fully covered → proceeds);
- the fast-path retirement: a plain re-read of a fully-covered doc still refuses with the byte-identical `ALREADY_IN_CONTEXT` (the row's observable contract is unchanged; its implementation now resolves first).
## Testing & Quality
- Unit: the full matrix + edges above; the refusal-text pins (`ALREADY_IN_CONTEXT` byte-identical; `REREAD_PARTIAL` exact copy); the pre-existing read-branch suite minus the re-pinned rows.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] a range read of a document earlier read ONLY as a different range proceeds (the owner's bug); a range read of fully-covered lines is refused with `ALREADY_IN_CONTEXT`
- [ ] a plain re-read of a partially-covered document gets `REREAD_PARTIAL` with the true covered span(s); `REREAD_TRUNCATED` no longer exists
- [ ] every byte-identical pin that is NOT re-pinned in task 02's matrix changes passes unchanged; `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work outside the documented matrix rows
@@ -0,0 +1,26 @@
# Task 03 — the owner's stuck scenario, proven: two ranges of one document both reach the model
**Phase:** `141_read_range_dedupe_fix` · **Source:** `TODO.md:3` — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a
## Objective
The bug the owner hit is locked as a test: an agent turn that greps, reads range A of a large document, then reads range B — both ranges' content reach the model and the answer can cite both — and a third read of range A is refused with the (now true) `ALREADY_IN_CONTEXT`.
## Work
1. `tests/integration/` (the read-branch integration home — the phase-131 handbook repro lives in the unit/integration suite per `.agents/phases/complete/131_read_line_ranges/03_handbook_repro.md`; follow that file's fixture conventions) — the two-range repro:
- fixture: ONE text document, ≥ 200 lines, with two distinct marker lines far apart (marker A near line ~50, marker B near line ~150) — the document's content is GENERATED in the test (no binary fixture; the handbook repro is the separate phase-131 asset, untouched);
- settings: `read_max_chars` high enough that the FULL document is under the cap is NOT the point — run BOTH variants: (a) doc under the cap (the pure dedupe case — the pre-phase code refused range B with `ALREADY_IN_CONTEXT`), (b) doc over the cap with a truncated first plain read (the phase-131 row, now via coverage: the range B read proceeds, the plain re-read gets `REREAD_PARTIAL`);
- scripted fake LLM (the `tests/unit/test_agent.py` round-shape pattern): round 1 → `read(path, offset=1, limit=40)` (range A); round 2 → `read(path, offset=<marker B's line − 10>, limit=30)` (range B); round 3 → `read(path, offset=1, limit=40)` again (the exact re-covered range A); round 4 → the answer echoing BOTH marker lines (the read-echo convention — the model can only echo what reached it).
- asserts: round 2's result is the range-B slice (its `lines:` header + the marker-B line present — NOT a refusal); round 3's result is `ALREADY_IN_CONTEXT`; round 4's echoed answer contains BOTH markers (the bug: pre-phase, the echo could contain only marker A).
2. `tests/unit/test_agent.py` — the matrix unit coverage from task 02 that is scenario-shaped rather than row-shaped: the adjacent-span edge as a scenario (two reads that together cover 1–100 of a 200-line doc → a plain re-read gets `REREAD_PARTIAL` naming `1–100`, and `read(offset=101)` proceeds).
## Testing & Quality
- Integration: the two-range repro (both variants) — the owner's scenario end-to-end through the agent loop.
- Unit: the scenario-shaped matrix edges above.
- Coverage: **>90%** on this task's new/modified code.
## Completion Criteria
- [ ] the two-range repro passes: range B's slice reaches the model after range A; the exact re-read of range A is refused; the final answer echoes both markers
- [ ] the truncated-variant passes: after a truncated first plain read, the range read proceeds and the plain re-read gets `REREAD_PARTIAL`
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the repro is additive; no existing test file is modified)
@@ -0,0 +1,29 @@
# Task 04 — the mock `read two ranges` flow + the dedicated E2E
**Phase:** `141_read_range_dedupe_fix` · **Source:** `TODO.md:3` — "From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: 'The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context"…' This seems like a critical bug, fix it."
**Story:** n/a
## Objective
The browser-visible proof: the deterministic mock LLM drives a two-range turn, and the dedicated Playwright file asserts that both `read` tool frames succeed and the final answer carries content from the SECOND range — the owner's stuck scenario, end-to-end through the SSE stream.
## Work
1. `tests/e2e/mock_llm.py` — ONE scripted flow, house style (a user-message marker; a module-header docstring entry like the phase-131 `read the lines` flow):
- marker: the user message contains `read two ranges`;
- request 1: a `read` tool call `{path: <the fixture doc>, offset: 1, limit: 100}`;
- request 2 (a `tool`-role read result whose FIRST line starts with `_READ_RESULT_PREFIX` (L1412) and which carries a `lines: 1-100 of …` header): a SECOND `read` tool call on the SAME doc with `{offset: 101, limit: 100}` — the pre-phase server would have answered the first read fine and refused the second; the flow only works if the second proceeds;
- request 3 (the second read result present — assert its `lines: 101-…` header to keep the mock's own keying honest): the answer "Both parts read." + the first 80 chars of the SECOND read result (the existing read-echo shape — the marker line lives in range B, so an echo of it proves the slice reached the model).
2. `tests/e2e/test_read_range_dedupe.py` — dedicated Playwright file, run in isolation, mock-only (the house fixture conventions of `tests/e2e/test_read_line_ranges.py` — truncate/re-import per module, `?v=` asset pins, the SSE assertion helpers):
- the KB fixture: ONE large text document (> 100 lines; range A lines 1–100 carry marker line `TWO_RANGES_A_LINE` near line ~50, range B lines 101+ carry marker line `TWO_RANGES_B_LINE` near line ~150);
- test 1: ask the `read two ranges` question → collect the SSE frames → assert exactly the two `tool` frames (both `read`, the second carrying `offset: 101`), NO refusal surface in either tool result (the results carry the `lines:` headers, not `Already in your context`), and the final `done` answer contains `TWO_RANGES_B_LINE`'s text;
- test 2 (the still-refused row, browser-visible): a second scripted marker flow is NOT added — instead reuse the phase-131 `read the lines` flow's existing E2E expectations (they still pass: the single-range behavior is unchanged) and add a focused integration-level assertion in `tests/unit/test_agent.py` if the mock surface can't express a refusal cleanly — the E2E's job is the HAPPY two-range path; the refusal row is unit-pinned in tasks 02/03.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Regression: `tests/e2e/test_read_line_ranges.py` + `tests/e2e/test_read_range_tool_lines.py` + `tests/e2e/test_read_truncation_cap.py` still pass in isolation (the phase-95/131/139 contracts).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] the mock flow's second range read succeeds through the real server (no refusal) and the answer echoes the second range's marker line
- [ ] `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` green in isolation; the three pre-existing read E2E files still green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the mock's pre-existing flows are byte-identical; the new flow is additive)
@@ -0,0 +1,36 @@
# Phase 142 — the KB overview stays fresh: newest documents survive the cap, and the model is told the outline is partial
**Source:** `TODO.md` L4 — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG… 'The <knowledge_base> section in the system prompt only generated summaries up to phase 113 at import time. The phases 128–137 exist as full markdown files… but their summaries weren't included in the initial KB index. This is a KB sync/generation gap, not a retrieval logic bug.'"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `app/rag/overview.py` — `regenerate_overview` (the document-list query `order_by(Document.source, Document.path)` ~L232, best-effort change-gated per the module docstring), `build_overview_prompt` (one line per doc `source — path — title — {first summary line}`, capped at `max_chars` with the shared `TRUNCATION_MARKER`), `load_kb_overview`; `app/config.py` L216 — `overview_input_max_chars` (`BOR_OVERVIEW_INPUT_MAX_CHARS`, default 40 000); `app/rag/prompts.py` — `_KB_INTRO` (L128–132: literally says "(generated at import time)") and `build_kb_section` (L399+); `app/api/sync.py` ~L590 — the overview regeneration gate (`summary.added + summary.updated > 0` — the sync IS change-gated correctly; the staleness is inside the generation, not the trigger); `tests/e2e/test_kb_overview.py` (the mock's `KB_OVERVIEW_MODE` digest = first 8 tokens of the document list — ORDER-DEPENDENT, must be re-pinned), `tests/integration/test_import_docs_overview.py` (the import-time trigger), `app/models.py` — `Document.created_at` (L123 — the DOCUMENT's date, phase 106: git-history/mtime-derived and owner-correctable, the same date the `read` result's `date:` line shows — the "when is this document from" signal, exactly what "the last N …" means). Root cause (traced): on the owner's 1 000-doc KB the document list is ≈100k+ chars; the 40k cap cuts a LEXICOGRAPHIC prefix (`order_by (source, path)` — "113_…" sorts before "128_…", and "131_…"–"140_" sort after "13_…"), so the newest phases never reach the lite model and the stored outline confidently describes a KB that ends mid-11x; the model then anchors "the last 10 phases" on that stale image.
**Locked (owner-confirmed 2026-09-30, chat):** recency ordering (newest documents first so they survive the cap; the cap itself is unchanged) + the honest `<knowledge_base>` intro (drops "(generated at import time)"; tells the model the outline is compact/possibly partial and to verify "latest/complete" questions with `ls`/`grep` first). No map-reduce rewrite this phase.
## Objective
"Summarize the last 10 phases" stops being a guess: the overview's input is ordered newest-first so recent documents are the ones the outline covers when the cap bites, and the `<knowledge_base>` intro stops teaching the model that a partial outline is the whole KB — it now says the outline is a possibly-partial picture and points at `ls`/`grep` for "what's the latest / the complete set" questions.
## Dependencies
- `141_read_range_dedupe_fix` (todo) — pipeline order only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
- **Ordering (task 02):** `regenerate_overview`'s query becomes `order_by(Document.created_at.desc(), Document.source, Document.path)` — newest documents FIRST (`created_at` is the document's date per phase 106 — the same date the `date:` line in a read result shows; the owner's phase-128–137 files carry newer git dates than the 113-era files, so they lead the list and survive the cap). The (source, path) secondary keys keep the tie-break deterministic for same-date docs (the common case within one commit/import — those fall back to the old lexicographic order, so same-date fixtures stay byte-identical). The cap (`BOR_OVERVIEW_INPUT_MAX_CHARS`, default 40 000) and the cap's cut+`TRUNCATION_MARKER` behavior are UNCHANGED (house truncation convention; the env knob stays the owner's lever). Zero other call-site changes: the sync's change-gate (task-31/32 trigger) already regenerates whenever the KB changes — the staleness was purely inside the list.
- **Intro (task 03):** `_KB_INTRO` rewritten (it is a prompt constant — PLAN §4's convention: persona/prompt text changes ship with the phase's prompt tests; the `<knowledge_base>` TAG and the section's cap behavior in `build_kb_section` stay byte-stable): the outline is a COMPACT, possibly partial picture of the KB that favors RECENT documents — and for questions about the latest state or the complete set ("the last N …", "everything about …"), verify with `ls` (drill down) / `grep` before answering from the outline. One short paragraph, same tone as `SUGGEST_INTRO` (L152).
- **Repro (task 01):** before any fix, a failing test proves the traced root cause: `build_overview_prompt` given a doc list whose joined lines exceed `max_chars` keeps the lexicographic FIRST lines and drops the last; and (integration) a KB where the newest docs' paths sort lexicographically LAST is regenerated with the pre-fix ordering → the stored outline (the mock's 8-token digest) names the OLD docs, not the new ones. The repro is written against the CURRENT code (red), then tasks 02/03 flip it green.
- **E2E (task 04):** dedicated file — a fixture KB whose document count × line length exceeds a FORCED small cap (`BOR_OVERVIEW_INPUT_MAX_CHARS` set low via the E2E settings fixture) → regenerate through the mock → the stored outline's digest tokens come from the NEWEST document (recency ordering visible end-to-end through the real prompt path), and the served chat prompt's `<knowledge_base>` section carries the NEW intro copy (asserted from the echo path or the direct prompt-build surface — the phase-31 E2E already exposes the section to the UI via the first-bullet echo).
## Tasks
1. `01_cap_ordering_repro.md` — the red repro tests (unit: cap keeps the lexicographic prefix; integration: newest-sorting-last docs missing from the regenerated outline).
2. `02_recency_ordering.md` — the `created_at.desc()` ordering + the order-dependent digest re-pins.
3. `03_honest_kb_intro.md` — the `_KB_INTRO` rewrite + the prompt tests.
4. `04_e2e_outline_freshness.md` — the dedicated E2E (forced cap → newest doc in the outline; the new intro served).
## Testing & Quality
- Unit: `build_overview_prompt` cap behavior unchanged (the cut + marker, the pathological budget); the new ordering (regeneration with mixed `created_at` docs → newest first; same-date tie-break by (source, path)); the intro copy pin.
- Integration: the import-time trigger still regenerates exactly on KB change (the pre-existing `tests/integration/test_import_docs_overview.py` green); the repro flipped green.
- E2E: one dedicated Playwright file, run in isolation, mock-only; `tests/e2e/test_kb_overview.py` (phase 31) green with re-pinned digest.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] with the cap forced, the regenerated outline is built from the NEWEST documents (the 1 000-doc "ends at phase 113" failure mode is gone: the newest sort-last docs are the ones represented)
- [ ] the served `<knowledge_base>` section carries the new honest intro (no "(generated at import time)"; the verify-with-`ls`/`grep` teaching present)
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_kb_overview_freshness.py -v --no-cov` green in isolation; `tests/e2e/test_kb_overview.py` still green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the cap env knob, the change-gated trigger, the `build_kb_section` cap/marker behavior, the deflect path)
@@ -0,0 +1,26 @@
# Task 01 — the red repro: the cap keeps a lexicographic prefix, so newest docs are the ones lost
**Phase:** `142_kb_overview_freshness` · **Source:** `TODO.md:4` — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG… this is a KB sync/generation gap, not a retrieval logic bug."
**Story:** n/a
## Objective
Lock the traced root cause as failing tests BEFORE the fix: `build_overview_prompt` cuts the document list at the cap keeping the FIRST lines (which, under the current `order_by (source, path)`, are the lexicographically first — NOT the newest), and a regeneration on a KB whose newest documents sort lexicographically LAST produces an outline that names the old documents. These tests are red on the current code and turn green in tasks 02/03.
## Work
1. `tests/unit/test_overview.py` (the phase-31 unit home — if the overview units live elsewhere, e.g. inside `tests/unit/test_agent.py`'s neighbors, use the existing file for `build_overview_prompt`'s pins) — the cap-ordering repro, unit level, no LLM:
- build 20 fake doc rows whose `source — path` prefixes are `s — 01_old_a.md` … `s — 20_new_z.md` (a path ordering that ALSO puts the "newest" last — the owner's phase-dir shape: names like `113_…` vs `128_…` vs `131_…`), each line ~100 chars, `max_chars=500` → the prompt's user message holds ONLY the first lines (assert: `01_old_a` present, `20_new_z` ABSENT, the `TRUNCATION_MARKER` line present — the current behavior pinned as-is);
- the assertion this task ADDS is the one that will flip: a helper-level check that documents the INTENT — the list reaching the model should lead with the NEWEST rows — written as the expected post-fix order (fails pre-fix).
2. `tests/integration/test_import_docs_overview.py` (or its sibling for regeneration — the file that already drives `regenerate_overview` against the mock) — the KB-shape repro, integration level:
- a fixture KB shaped like the owner's: one source, folders/paths whose names sort lexicographically BEHIND their content's recency (e.g. an `old/…` batch + a `zz_new/…` batch whose paths sort LAST). The batches' `created_at` (the document date — phase 106 derives it from the file's mtime for a local fixture) must differ: set the files' mtimes (`os.utime`) before import so the old batch is genuinely older, OR update the old batch's `created_at` to a past date in a raw SQL step after import — the test owns its fixture, either seam is fine,
- regenerate through the mock `KB_OVERVIEW_MODE` (the 8-token digest of the document list, `tests/e2e/mock_llm.py` ~L2906) with a small `BOR_OVERVIEW_INPUT_MAX_CHARS` so the cap bites,
- assert the CURRENT failure: the stored outline's tokens come from the lexicographically-first (oldest) documents, NOT the newest — the exact "summaries only up to phase 113" shape, reproducible in a test.
- Mark both tests with the outcome they expect POST-fix (newest docs win); they FAIL now — that red is the repro. (Task 02 makes them green.)
## Testing & Quality
- Unit/integration: the two repro tests above — RED on the current code (asserted by running them in this task), green after task 02 (+ task 03 for the intro).
- Coverage: **>90%** on this task's new/modified code (tests — coverage applies to `app/` as a whole).
## Completion Criteria
- [ ] the two repro tests exist and FAIL on the pre-fix code for the documented reason (lexicographic-prefix cap; oldest docs in the outline) — the failure output names the root cause
- [ ] no application code changed in this task (the repro is tests-only)
- [ ] the rest of `uv run pytest` stays green (the repro tests are the only new failures)
@@ -0,0 +1,27 @@
# Task 02 — recency ordering: newest documents first in the overview's input
**Phase:** `142_kb_overview_freshness` · **Source:** `TODO.md:4` — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG… this is a KB sync/generation gap, not a retrieval logic bug."
**Story:** n/a
## Objective
`regenerate_overview` builds the lite model's document list newest-first, so when `BOR_OVERVIEW_INPUT_MAX_CHARS` cuts the list, the documents that fall off are the OLDEST — and the outline (the model's a-priori KB picture) always represents the recent state of the KB. Task 01's red repro turns green.
## Work
1. `app/rag/overview.py` — `regenerate_overview`'s document-list query: `order_by(Document.created_at.desc(), Document.source, Document.path)`. Update the query's inline comment + the module docstring (the "one line per document" description): the ordering is RECENT-FIRST by the DOCUMENT's date (phase 106's `created_at` — git-history/mtime-derived, owner-correctable, `created_at_manual` rows included exactly as stored: the ordering reads the column, it never re-derives dates; the (source, path) tie-break keeps same-date docs in the old lexicographic order — deterministic for the mock's digest, and byte-identical for single-date fixtures). The cap, the cap's cut + `TRUNCATION_MARKER`, the zero-docs no-op, the `LLMError` fail-soft, the upsert — ALL unchanged.
2. `tests/unit/test_overview.py` — the unit pins:
- the task-01 cap test flips: with mixed `created_at` rows (the row shape `(source, path, title, summary)` is order-agnostic — the ORDER comes from the query, so the unit test for the QUERY ordering goes against the DB: a small session with 6 docs in two `created_at` batches → the SELECT returns newest batch first, (source, path) within a batch),
- the byte-identical pins for `build_overview_prompt` itself are UNTOUCHED (it still receives rows in whatever order the caller passes — its contract is order-preserving; only the CALLER's order changes).
3. `tests/integration/test_import_docs_overview.py` — the integration repro flips green: the second (newer) import batch's paths now lead the digest.
4. Re-pin the ORDER-DEPENDENT digest expectations that assumed the old order (the mock's 8 tokens are the first document line's tokens — under recency-first that is now the NEWEST doc's line):
- `tests/e2e/test_kb_overview.py` — `test_mock_generated_outline_is_stored_and_echoed`'s expected digest (compute it from the fixture's newest document, not the lexicographic first — the test's fixture documents and their `created_at` values decide; the test seeds its row directly for tests 1–3, so only test 4's regeneration digest is order-sensitive),
- any other pin asserting a specific first line of a regenerated overview (grep the suite for the digest literal — re-pin where the KB has more than one document with differing `created_at`; single-batch fixtures where every doc shares a `created_at` stay byte-identical by the (source, path) tie-break).
## Testing & Quality
- Unit/integration: the ordering tests + the flipped repro (task 01) + the re-pinned digests.
- Regression: the phase-31 `KB_OVERVIEW_MODE` branch in `tests/e2e/mock_llm.py` is UNCHANGED (it digests whatever list the app sends — the mock is order-agnostic by construction); `tests/e2e/test_kb_overview.py` green in isolation after the re-pins.
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] task 01's two repro tests are GREEN (the newest documents lead the capped list; the outline represents the recent KB)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_kb_overview.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases beyond the list ORDER (the cap env knob, the trigger gate, the fail-soft, the upsert, the mock's generation branch — all byte-identical)
@@ -0,0 +1,26 @@
# Task 03 — the honest `<knowledge_base>` intro: the outline is partial, and "latest" questions verify first
**Phase:** `142_kb_overview_freshness` · **Source:** `TODO.md:4` — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG…"
**Story:** n/a
## Objective
The model stops treating the outline as the KB's full, import-time-frozen inventory: `_KB_INTRO` says what the outline actually is (a compact, possibly partial picture that favors recent documents) and gives the one behavior that fixes the owner's question class — for "latest / complete set" questions, verify with the tools (`ls` drill-down / `grep`) before answering from the outline.
## Work
1. `app/rag/prompts.py` — rewrite `_KB_INTRO` (L128–132). Current: `"The basic categories of everything in this knowledge base (generated at import time):\n"`. New copy (short — the section is char-budgeted by `build_kb_section`; keep it 1–2 lines): the outline is a COMPACT picture of the knowledge base's categories and topics that FAVORS RECENT DOCUMENTS and may omit older or lesser ones — it is a map of what exists, not a complete inventory; for questions about the LATEST state or the COMPLETE set ("the last N …", "every …", "all the …"), verify with `ls` (drill into the folders) or `grep` before answering from the outline. The "(generated at import time)" phrase is GONE (it is false — the sync regenerates the row on KB change — and it taught the model to discount/freeze the section).
2. `app/rag/prompts.py` — leave `build_kb_section` (L399+) byte-stable: same tag, same cap loop, same pathological-budget behavior — ONLY the intro constant changes. Update the constant's doc comment (the phase-31 note) with this phase's rationale in one line.
3. Prompt tests — the house prompt-suite pins (`tests/unit/` — the file pinning `build_kb_section` / the HIGH prompt's section order, the phase-31 pins):
- the new intro copy pinned verbatim (the exact string),
- a no-overview prompt stays byte-identical to the pre-phase text (the phase-15 convention — the existing pin passes unchanged: an empty overview omits the whole section),
- the section's cap/marker behavior pins pass unchanged (the intro is inside the budget loop — a one-line-longer intro changes the pathological-budget edge only if a test pinned the exact cut against the OLD intro length; re-pin any such test to the new intro length — the BEHAVIOR is unchanged),
- the HIGH prompt's section-order pin (`<relevance>` → `<knowledge_base>` → `<tuning>` → body) passes unchanged.
4. The deflect path is UNTOUCHED (the LOW prompt carries no `<knowledge_base>` section — the existing byte-identity pins pass as-is).
## Testing & Quality
- Unit: the intro copy pin; the section-build pins (cap, marker, pathological budget, empty-omission); the section-order pin.
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] `_KB_INTRO` no longer contains "(generated at import time)"; it names the recency bias and the verify-with-`ls`/`grep` behavior (pinned verbatim)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the section's tag/cap/marker, the deflect prompt's byte-identity, the section order)
@@ -0,0 +1,28 @@
# Task 04 — the dedicated E2E: the regenerated outline leads with the newest document, and the new intro is served
**Phase:** `142_kb_overview_freshness` · **Source:** `TODO.md:4` — "The agent is really struggling with questions like 'Summarize the last 10 phases of brain of Reese'. When I asked that to the current deployment it said 'I'll pull the details for the last 10 phases (104–113).' when it clearly had phase 137 loaded in the RAG…"
**Story:** n/a
## Objective
The browser-visible proof of the whole phase: with the cap forced to bite, a sync-path regeneration produces a stored outline whose digest comes from the NEWEST document (recency ordering, end-to-end through the real prompt path), and the served chat prompt's `<knowledge_base>` section carries the new honest intro.
## Work
1. `tests/e2e/test_kb_overview_freshness.py` — dedicated Playwright file, run in isolation, mock-only (the house conventions of `tests/e2e/test_kb_overview.py` — per-module truncate/re-import, the settings override fixture, the login helper, the SSE echo assertions):
- fixture: one source, TWO batches of documents whose document DATES differ (the old batch's files get past mtimes via `os.utime` before import, the new batch is current — phase 106's mtime fallback for local sources; the new batch's PATHS sort lexicographically LAST, e.g. `old/a.md`… vs `zz_new/z.md` — so the pre-fix lexicographic order and the post-fix recency order disagree about which document is first),
- settings: `BOR_OVERVIEW_INPUT_MAX_CHARS` set small enough (via the E2E settings override) that the document list is CUT — the cap must bite or the ordering is unobservable;
- test 1 (`test_regenerated_outline_leads_with_newest_doc`): import, then drive the regeneration through the mock the same way `test_kb_overview.py` test 4 does (the real `regenerate_overview` against the mock's `KB_OVERVIEW_MODE` digest) → the stored `kb_overview` row's 8 digest tokens come from the NEWEST batch's first (recency-ordered) line — assert the exact tokens (computed from the fixture in the test, not hardcoded wording);
- test 2 (`test_kb_section_carries_honest_intro`): the intro is a prompt constant — the deterministic surface is the section builder itself, called IN the E2E file the same way this file already calls `regenerate_overview`/`build_overview_prompt` directly against the app (the phase-31 file's own convention): `build_kb_section(<a seeded multi-line outline>)` → assert the section's lead line IS the new intro verbatim (no "generated at import time"), the tag + cap behavior intact; the UI injection path (the section reaching a rendered turn) is the phase-31 first-bullet echo, which a third lightweight test re-asserts here against the SEEDDED row so this file stands alone on the injection surface too;
- the pre-existing phase-31 file is NOT modified here (its re-pins landed in task 02).
2. Run the regression set in isolation (each file its own run — the suite-isolation contract): `test_kb_overview_freshness.py` (new), `test_kb_overview.py` (phase 31, task-02 re-pins).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_kb_overview_freshness.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Integration: the intro-observation fallback (if test 2 takes the `build_kb_section` surface) lives in the unit/integration suite, not the E2E.
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] with the cap biting, the regenerated outline's digest tokens come from the NEWEST document (the owner's "ends at phase 113" shape is impossible — the newest docs lead)
- [ ] the new intro copy is pinned verbatim as the section's lead line (the `build_kb_section` surface), and the section still reaches a rendered turn (the phase-31 echo path)
- [ ] `uv run pytest tests/e2e/test_kb_overview_freshness.py -v --no-cov` green in isolation; `tests/e2e/test_kb_overview.py` still green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the phase-31 injection path, the mock's generation branch)
@@ -0,0 +1,32 @@
# Phase 143 — the mobile keyboard stays down after an answer completes
**Source:** `TODO.md` L5 — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `frontend/assets/app.js` — the turn's `finally` block (~L3478–3493): `// done | error | stop → idle: always settle, always focus back.` … `input.focus({ preventScroll: true })` (L3492) — EVERY turn end (done, error, stop) re-focuses the composer; on a phone, focusing the textarea pops the virtual keyboard the moment the answer completes (the owner's annoyance). The existing `matchMedia` precedent in the file (L391: `reducedMotion` for `prefers-reduced-motion` — the module-level-constant shape); `tests/unit/test_frontend_scroll.py` — `test_turn_end_focus_does_not_scroll` (L213+: the source-level pin that the finally block contains the `preventScroll` focus line — must be EXTENDED, not broken); the composer is a ≥44px tap target on mobile (PLAN §7.1).
**Locked (owner-confirmed 2026-09-30, chat):** desktop-only refocus — the post-turn focus-back is skipped on coarse-pointer devices (`matchMedia("(pointer: coarse)")`), kept on desktop so the next question can be typed immediately.
## Objective
A phone user reads the completed answer with the keyboard DOWN: the composer is only re-focused at turn end on fine-pointer (desktop) devices; a coarse-pointer device stays unfocused (the next question starts with a tap on the ≥44px composer). Desktop behavior — including the never-scroll focus contract — is byte-identical.
## Dependencies
- `142_kb_overview_freshness` (todo) — pipeline order only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The gate (task 01):** a module-level constant beside the existing `reducedMotion` one (app.js ~L391): `const COARSE_POINTER = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;` (the L391 shape — the `typeof` guard keeps the constant safe where `matchMedia` is absent, the house style for the reduced-motion line). The finally block's focus line becomes GATED: `if (!COARSE_POINTER) input.focus({ preventScroll: true });` with a phase-143 comment explaining WHY (the keyboard pop) and the §7.1 tap-target note. The `preventScroll` argument and the never-scroll contract (phase 42 / the `test_turn_end_focus_does_not_scroll` intent) are UNCHANGED on desktop; the error/stop paths take the SAME gate (one focus line, one gate — the finally block is the single settle).
- **The pin (task 01):** `tests/unit/test_frontend_scroll.py::test_turn_end_focus_does_not_scroll` is EXTENDED: the finally block still contains the `preventScroll` focus call (now inside the `if (!COARSE_POINTER)` guard) and the `COARSE_POINTER` constant is defined from `matchMedia("(pointer: coarse)")` — the pin asserts the GATED shape (a bare ungated `input.focus(...)` line in the block is a failure, as today).
- **The E2E (task 02):** dedicated file, two contexts — desktop (default Chromium, no touch) and mobile (a Playwright device descriptor with `isMobile` + `hasTouch` — the descriptor's touch emulation makes Chromium report `pointer: coarse`). Each context: a mock-LLM turn to completion (the house E2E fixture — a single on-topic question, the done state, the same assertions as the chat story files), then assert `document.activeElement` — desktop: the composer's textarea (the phase-06 "always focus back" contract, preserved); mobile: NOT the composer (the keyboard stays down). A sanity assert in the mobile test: `page.evaluate(() => matchMedia("(pointer: coarse)").matches)` is true (the emulation is doing its job — if a future Playwright/Chromium change breaks the emulation, the test fails LOUDLY on the sanity line, not mysteriously on the focus line).
## Tasks
1. `01_coarse_pointer_focus_gate.md` — the constant + the gated focus line + the extended source pin.
2. `02_e2e_mobile_focus.md` — the dedicated two-context E2E.
## Testing & Quality
- Unit: the extended source-level pin (the gated focus shape; the constant definition); every other `app.js` pin passes unchanged.
- E2E: one dedicated Playwright file, run in isolation, mock-only (desktop + mobile contexts).
- Coverage: **>90%** on new/modified code (this phase is frontend — the `app/` coverage gate is unaffected and must stay green).
## Completion Criteria
- [ ] desktop: after a turn ends (done, error, stop), the composer is focused with `preventScroll` — byte-identical to pre-phase
- [ ] mobile (coarse pointer): after a turn ends, the composer is NOT focused — the keyboard stays down; the user's tap starts the next question
- [ ] `uv run pytest` green (the extended pin passes); `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the never-scroll focus contract on desktop, the stop path's single settle, the char counter, the send flow)
@@ -0,0 +1,38 @@
# Task 01 — the coarse-pointer gate on the post-turn focus-back
**Phase:** `143_mobile_keyboard_after_answer` · **Source:** `TODO.md:5` — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a
## Objective
The turn's `finally` block re-focuses the composer only on fine-pointer devices: a coarse-pointer (mobile) turn end leaves the page unfocused, so the virtual keyboard does not pop back up over the completed answer.
## Work
1. `frontend/assets/app.js` — beside the existing reduced-motion constant (~L391: `const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;`), add the twin constant: `const COARSE_POINTER = typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches;` — a short phase-143 comment: the coarse-pointer state of THIS device (touch phones/tablets), the keyboard-pop gate for the turn-end focus-back.
2. `frontend/assets/app.js` — the turn's `finally` block (~L3478–3493): the last line `input.focus({ preventScroll: true });` becomes:
```js
// Phase 143 (TODO L5): the focus-back is a DESKTOP courtesy — on a
// coarse pointer (a phone/tablet) focusing the composer pops the
// virtual keyboard over the completed answer, which is exactly what
// the owner found annoying. Fine pointers keep the immediate
// refocus (type the follow-up without moving a hand); a coarse
// pointer stays unfocused — the composer is a ≥44px tap target
// (PLAN §7.1), so the next question starts with a tap. The
// never-scroll contract (phase 42) and the single-settle rule are
// unchanged on every device — only the focus call is gated.
if (!COARSE_POINTER) input.focus({ preventScroll: true });
```
The comment above the line ("Focus back for the next question, but never move the viewport…") is folded into the phase-143 comment (one comment block, no stale duplicate). The block's SETTLE lines (clearTurnTimeout / stopThinkingClock / cancelStream / setUiState / the stop status line) are UNTOUCHED — the gate wraps the focus call alone.
3. `tests/unit/test_frontend_scroll.py` — EXTEND `test_turn_end_focus_does_not_scroll` (L213+):
- the finally block (located by the existing `// done | error | stop → idle: always settle, always focus back` anchor — KEEP the anchor comment in app.js verbatim or update the anchor in lockstep) now asserts the GATED shape: `if (!COARSE_POINTER) input.focus({ preventScroll: true })` is in the block, and a bare ungated `input.focus(` occurrence (the pre-phase line) is NOT,
- and the constant: `matchMedia("(pointer: coarse)")` appears in the file with the `COARSE_POINTER` binding (the L391-twin shape),
- the docstring is updated to state the phase-143 gate (the pin's job is now: focus-back exists, is preventScroll, and is coarse-gated).
## Testing & Quality
- Unit: the extended pin + every other frontend source pin green (`uv run pytest tests/unit/ -k frontend`).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green: `uv run pytest --cov=app`).
## Completion Criteria
- [ ] desktop (fine pointer): the turn end focuses the composer with `preventScroll` — the phase-06/42 contract byte-preserved
- [ ] coarse pointer: the turn end does NOT focus the composer (the keyboard stays down)
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the settle lines, the error/stop paths, the char counter, the send flow — the gate wraps the focus call alone)
@@ -0,0 +1,26 @@
# Task 02 — the dedicated two-context E2E: the keyboard stays down on mobile
**Phase:** `143_mobile_keyboard_after_answer` · **Source:** `TODO.md:5` — "mobile keyboard pops back up after answer is complete and that's annoying"
**Story:** n/a
## Objective
The browser-visible proof on BOTH devices: a completed turn leaves the composer focused on desktop (the preserved contract) and UNFOCUSED on a mobile-emulated context (the owner's fix) — deterministic, mock-only, in one dedicated file.
## Work
1. `tests/e2e/test_mobile_keyboard_focus.py` — dedicated Playwright file, run in isolation, mock-only (the house E2E conventions — the per-module fixture import, the login helper, the SSE/done-state waits from the chat story files):
- a small shared helper in the file: `turn_to_completion(page)` — asks one on-topic question (the fixture's marker question, the mock answers immediately), waits for the done state (the `aria-live` stream settled / the send button back to its idle contract — the same wait the chat story E2Es use).
- test 1 (`test_desktop_turn_end_refocuses_composer`): the DEFAULT Chromium context (no touch — `pointer: fine`): `turn_to_completion` → assert `page.evaluate("document.activeElement")` IS the composer's textarea (locate by the composer's selector — the `input` the app.js module owns; identify it via the existing selector the E2E helpers already use for typing questions). This is the regression guard: the phase-06 "always focus back" contract on desktop.
- test 2 (`test_mobile_turn_end_keeps_keyboard_down`): a MOBILE context — a Playwright device descriptor with `isMobile: true` + `hasTouch: true` (e.g. the built-in Pixel/iPhone descriptor, or an explicit `{ viewport, isMobile, hasTouch }` — the mobile ≤640px CSS breakpoint must also apply, so the descriptor's viewport is the deciding factor): FIRST a sanity assert `page.evaluate("matchMedia('(pointer: coarse)').matches")` is TRUE (the emulation flips the media feature — if a future Playwright/Chromium change breaks that, this line fails loudly and names why, instead of the focus assertion failing mysteriously), THEN `turn_to_completion` → assert `document.activeElement` is NOT the composer's textarea (the keyboard stays down — the owner's complaint is gone).
- both contexts run against the SAME fixture KB and the SAME question — the only variable is the device.
2. Run the regression set in isolation: this file + ONE chat story E2E that exercises the turn-end state (e.g. the phase-06 loading-feedback or the phase-48 stop-generation file — whichever asserts the post-turn UI) to prove the desktop contract survived.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate unaffected).
## Completion Criteria
- [ ] desktop context: after a completed turn, the composer is the active element
- [ ] mobile context: the `pointer: coarse` sanity assert passes AND after a completed turn the composer is NOT the active element
- [ ] `uv run pytest tests/e2e/test_mobile_keyboard_focus.py -v --no-cov` green in isolation; the chosen chat story E2E still green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the E2E is additive; no app/frontend change in this task)
@@ -0,0 +1,38 @@
# Phase 144 — the browser back button closes the document modal, not the view behind it
**Source:** `TODO.md` L6 — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `frontend/assets/document-modal.js` — the single owner of `#doc-modal` (the near-fullscreen document viewer; `openDocumentModal` L141+ — trigger memory, the Escape/Tab capture, the `fetchSeq` stale-guard; `closeDocumentModal` — hide, drop the capture, restore focus to the trigger; the close affordances: `#doc-modal-close`, backdrop click, Escape); `frontend/assets/router.js` — the shell's popstate (L283–286: reads the PATHNAME only — a state-only entry's pop is a no-op for it); `frontend/assets/sources.js` — the existing STATE-ONLY-entry convention (L237–260, L1539: `history.pushState({ view: "rag", kb: target }, "")` — the second arg `""` keeps the current URL) and its popstate adopt/reset (L1949–1954: adopts the popped entry's `kb` or resets the drill — a pop onto the SAME state the view already shows is invisible); `tests/unit/test_remove_confirm_modal.py` (the source-level modal-pin precedent); the phase-99 D2 "the browser owns its entries" convention.
**Locked (owner-confirmed 2026-09-30, chat):** scope = the document modal (`#doc-modal`) only — the Sources-page admin confirm dialogs (the `remove-confirm` / `ignore-editor` `<dialog>` elements) are quick confirmations, not "modals you look at", and stay as-is. Back closes the modal via a state-only history entry; forward re-opens the modal (the browser owns its entries).
## Objective
On mobile and desktop alike, the browser Back button while the document modal is open CLOSES the modal and leaves the user exactly where they were (same view, same URL, same Sources drill) — instead of navigating the view behind the still-open modal. A Back-then-Forward re-opens the same document; a button/Escape/backdrop close leaves the history exactly as if the modal had never been opened.
## Dependencies
- `143_mobile_keyboard_after_answer` (todo) — pipeline order only; no code dependency.
## Design (shared by all tasks — the executor reads this, not the chat)
The modal becomes history-aware with ONE state-only entry per open — the `sources.js` convention, keyed on a `modal` state key the router (pathname-only) and the sources drill (`kb` key) both ignore:
- **Open** (`openDocumentModal`, after the skeleton guard — the `window.open` fallback path pushes NOTHING): remember the entry we are sitting on (`prev = history.state`), then — IF AND ONLY IF the current entry is not already a modal entry — `history.pushState({ modal: "doc", source, path }, "")` (state-only: the URL does not move). The "not already a modal entry" rule has exactly two callers: the normal open (sitting on the view's entry → push) and the forward re-open below (sitting on the modal's own entry → adopt, no push). If the modal is ALREADY open (defensive — the overlay makes it unreachable by pointer, but the programmatic contract must not stack entries), `replaceState` the existing modal entry in place (the remembered `prev` is kept) instead of pushing.
- **Back** (the popstate handler, armed once at module top level like the other bindings): `popstate` fires with the state of the entry we LANDED ON. The modal is open and the landed state is NOT a `modal` entry → the browser moved off the modal's entry → that is the back press: remember `lastPrev = the landed state` (the entry below the modal's — needed for the forward re-open's restore), close the modal (a `closingViaBack` flag around the call so the close does not also `replaceState`). The router's popstate is a no-op (same pathname → `name === current`); the sources drill re-adopts the SAME state it was already showing (its `kb` key is the state we sat on before the push — invisible). Net: the modal closes, nothing else moves.
- **Forward onto the modal's entry** (modal closed, `event.state.modal === "doc"`): the browser owns its entries (phase-99 D2) — re-open the document the entry names: `openDocumentModal(st.source, st.path, null)` (trigger null — focus goes to the content target, the normal open path). The open sees the current entry IS a modal entry → ADOPTS it instead of pushing: `entry = { prev: lastPrev, source, path }` (`lastPrev` — the below-entry remembered at the back-close; it is always available: a modal entry can only be in the forward stack because a back-close popped it first).
- **Button / Escape / backdrop close** (not a back press): the modal entry is still the current entry — remove it IN PLACE with `history.replaceState(entry.prev, "")` (no popstate fires, no other listener runs, the view state is restored byte-for-byte) — so the NEXT back behaves exactly as if the modal had never been opened. Guard: run the `replaceState` only when `history.state` is still a modal entry (the invariant — the three affordances are reachable only while open, and open means sitting on the modal entry; the check is the fail-safe if the invariant ever breaks: do nothing to the history rather than corrupt the current entry).
- Module state: `let entry = null` — `{ prev, source, path }` while the modal is open (the `prev` to restore, the doc the entry names); `let lastPrev = null` — the below-entry remembered at each back-close (the adopt's `prev`); both `null`/stale otherwise. The popstate handler and the open/close paths are the ONLY writers. Known edge (documented, accepted): if the user opens the modal, backs out, switches views, then FORWARDS onto the modal entry, the modal re-opens over the new view — the browser owns its entries (the sources drill does the same with its `kb` entries); the modal is always one close-away.
## Tasks
1. `01_history_aware_modal.md` — the push/replace/popstate mechanics in `document-modal.js` (open, back, forward, the three close affordances).
2. `02_unit_pins.md` — the source-level unit pins (the `test_remove_confirm_modal.py` pattern) for the entry lifecycle and the listener interplay.
3. `03_e2e_back_closes_modal.md` — the dedicated E2E: back closes the modal (URL + view + drill untouched), button-close keeps history sane, forward re-opens.
## Testing & Quality
- Unit: the source-level pins (the push on open, the `closingViaBack`-guarded close, the `replaceState(prev)` on non-back close, the popstate handler's two branches, the re-open suppression).
- E2E: one dedicated Playwright file, run in isolation, mock-only — `page.goBack()` / `page.goForward()` drive the real history.
- Regression: `tests/e2e/test_document_back_navigation.py` (the phase-13 /document.html back-nav story — the FULL-PAGE viewer, a different page: it must stay green) and the phase-99 Sources drill E2E (the kb back/forward story — the interplay must not leak).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] with the modal open: Back → the modal closes, the URL is unchanged, the view is unchanged, the Sources drill (when opened from Sources) is unchanged; a SECOND Back then leaves the view as usual
- [ ] Back → Forward → the same document re-opens in the modal (no duplicate history entry)
- [ ] button / Escape / backdrop close → the next Back behaves exactly as if the modal had never been opened (no dead no-op back step)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` green in isolation; the two regression E2Es still green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the router's popstate, the sources drill's adopt/reset, the modal's focus trap / Escape / fetch-seq contracts, the admin confirm dialogs)
@@ -0,0 +1,93 @@
# Task 01 — the history-aware modal: push, adopt, back-close, in-place removal
**Phase:** `144_modal_back_button` · **Source:** `TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
`#doc-modal` owns one state-only history entry per open: Back closes the modal (view/URL/drill untouched), a non-back close removes the entry in place (the next Back behaves as if the modal never opened), and Forward onto the modal's entry re-opens the same document.
## Work
1. `frontend/assets/document-modal.js` — module state beside the existing `triggerEl` / `fetchSeq` lets: `let entry = null;` (`{ prev, source, path }` while the modal is open — `prev` is the below-entry to restore, `source`/`path` are the doc the entry names) and `let lastPrev = null;` (the below-entry remembered at each back-close — the forward re-open's restore source). Comment each on the `fetchSeq` house style (what it is, who writes it).
2. `frontend/assets/document-modal.js` — `openDocumentModal` (L141+), after the skeleton guard (the `window.open` fallback stays BEFORE this — it pushes nothing) and before `modalEl.hidden = false`:
```js
/* Phase 144 (TODO L6): the modal owns ONE state-only history entry —
* Back closes the modal instead of navigating the view behind it.
* The entry is state-only (second arg "" — the URL does not move),
* so the router's pathname-only popstate is a no-op and the Sources
* drill re-adopts the state it was already showing (the sources.js
* state-only-entry convention, L1539). The current entry is a modal
* entry only on the forward re-open (the popstate handler below
* calls us sitting on it) — then we ADOPT it; otherwise we push. */
const st = history.state;
if (entry) {
// Defensive re-open while open: replace our entry in place (the
// remembered prev is kept) — never stack a second entry.
entry.source = source;
entry.path = path;
history.replaceState({ modal: "doc", source, path }, "");
} else if (st && st.modal === "doc") {
// Forward onto our own entry: adopt — the browser already sits on
// it (lastPrev is the below-entry remembered at the back-close).
entry = { prev: lastPrev, source, path };
} else {
entry = { prev: st, source, path };
history.pushState({ modal: "doc", source, path }, "");
}
```
(The rest of the open body — trigger memory, `showLoading`, the Escape/Tab capture, the fetch — is untouched.)
3. `frontend/assets/document-modal.js` — `closeDocumentModal` (L~200): at the top (after the existing `if (!modalEl || modalEl.hidden) return;`), before the hide, add the entry removal:
```js
/* Phase 144: a NON-back close removes the modal's entry IN PLACE —
* no popstate fires, no other listener runs, the view state is back
* byte-for-byte, so the next Back behaves as if the modal never
* opened. A back close (closingViaBack — the popstate handler) has
* already been moved off the entry by the browser: no replace. */
if (entry && !closingViaBack && history.state && history.state.modal === "doc") {
history.replaceState(entry.prev, "");
}
entry = null;
```
with `let closingViaBack = false;` beside the other lets (reset by the popstate handler after each close call — it is set ONLY around that call).
4. `frontend/assets/document-modal.js` — the popstate listener, armed ONCE at module top level (next to the existing `closeEl`/`backdropEl` bindings — a missing skeleton means the listener is a no-op via the `entry`/`modalEl` guards, the header.js pattern):
```js
/* Phase 144 (TODO L6): back/forward onto the modal's entry. popstate
* fires with the state of the entry we LANDED on. Back: the modal is
* open and we landed on a non-modal state — the browser moved off our
* entry; close the modal (remember the landed state as lastPrev for
* the forward re-open; closingViaBack keeps the close from also
* replaceState-ing). Forward: the modal is closed and we landed on a
* modal entry — the browser owns its entries (the phase-99 D2
* convention) — re-open the doc the entry names (the open adopts the
* entry — no second push). Every other popstate (the router's view
* entries, the drill's kb entries) passes through untouched. */
window.addEventListener("popstate", (event) => {
const st = event.state;
const isModalEntry = st && st.modal === "doc";
if (isModalEntry && modalEl && modalEl.hidden) {
openDocumentModal(st.source, st.path, null);
return;
}
if (!isModalEntry && modalEl && !modalEl.hidden && entry) {
lastPrev = st;
closingViaBack = true;
closeDocumentModal();
closingViaBack = false;
}
});
```
5. Update the module's header docstring contract block (the `openDocumentModal` / `closeDocumentModal` bullets, L12–20): one bullet for the history behavior (the entry, Back closes, the in-place removal, the forward re-open) — the file's self-documentation convention.
- ASSUMPTION: the state key is `modal: "doc"` (a string discriminator, not a unique token) — a second modal type would use its own key (the `kb` / `view` key convention); the value pair (`source`, `path`) rides in the state so the forward re-open names its document (percent-encoding is irrelevant — the state is structured, not a URL).
- ASSUMPTION: `lastPrev` (not `history.state`) is the adopt's `prev` — at a forward re-open the browser sits ON the modal entry, so the below-entry is only the one remembered at the back-close; a modal entry can reach the forward stack only through a back-close, so it is always available (the null case — e.g. a forward after a hard page reload into a modal URL — is impossible: modal entries are same-document pushes, never URL-deep-links).
## Testing & Quality
- Unit: task 02's source-level pins (this task ships with them green — the pins are written against the final shape above).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] open → one state-only entry (URL unchanged); re-open-while-open → in-place replace, still one entry
- [ ] Back with the modal open → the modal closes, `lastPrev` remembered, no `replaceState` (the browser owns the pop)
- [ ] button/Escape/backdrop close → `replaceState(entry.prev)` runs (the entry is gone in place); the next Back is a real navigation step, not a dead no-op
- [ ] Forward onto the modal's entry → the same document re-opens, no second push
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the router's popstate, the sources drill, the modal's focus trap / Escape / fetch-seq / trigger-focus contracts — the new state sits beside them)
@@ -0,0 +1,27 @@
# Task 02 — the source-level pins: the entry lifecycle, the two popstate branches, the no-leak guarantees
**Phase:** `144_modal_back_button` · **Source:** `TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
A silent regression in the history mechanics (a double push, a replaceState on the wrong entry, a listener leak, a lost `lastPrev`) is caught at the source level — the `test_remove_confirm_modal.py` house pattern for a frontend module whose real behavior is E2E-gated.
## Work
1. `tests/unit/test_frontend_doc_modal.py` — new file (the `test_remove_confirm_modal.py` pattern: read `frontend/assets/document-modal.js` as text — the `_js()`-style loader, REPO-relative path; if a doc-modal unit file already exists, extend it instead):
- **the state:** `let entry = null` and `let lastPrev = null` and `let closingViaBack = false` are declared exactly once (one `let` each — no second writer site);
- **the open:** the push is `history.pushState({ modal: "doc", source, path }, "")` (state-only — the `""` second arg present — the URL must not move); the push sits INSIDE the else-branch behind the `st && st.modal === "doc"` adopt check (assert the adoption branch assigns `entry = { prev: lastPrev, source, path }` WITHOUT a pushState — the no-second-push rule); the defensive re-open branch uses `history.replaceState` and keeps `entry` (updates `source`/`path`);
- **the close:** the in-place removal `history.replaceState(entry.prev, "")` is guarded by `!closingViaBack` AND `history.state && history.state.modal === "doc"` (the fail-safe — a non-modal current entry is never overwritten); `entry = null` runs unconditionally in the close (the adopt's memory must not survive a close);
- **the popstate:** the listener is registered ONCE (one `window.addEventListener("popstate"` in the file); its two branches are mutually exclusive (the `isModalEntry && modalEl.hidden` forward-re-open branch RETURNS before the back-close branch); the back-close branch writes `lastPrev = st` BEFORE the `closingViaBack`-wrapped `closeDocumentModal()` call; no branch fires when `entry` is falsy / the modal is closed and the state is not a modal entry (the pass-through);
- **no leak:** the popstate listener and the entry state are the ONLY `history.*` call sites in the file (a grep-style assert: the file's `history.` occurrences are exactly the push, the two replaces, and nothing else — `history.back()` / `history.go()` must NOT appear — the browser owns its entries);
- **the contract unchanged:** the pre-phase pins that a regression could plausibly break still hold — the Escape → `closeDocumentModal()` capture, the `fetchSeq` stale-guard lines, the trigger-focus restore in the close, the `window.open(fullPageUrl…)` fallback BEFORE the entry logic (the fallback pushes nothing — assert its early `return` precedes the first `history.` use in the open).
2. Run the frontend unit set: `uv run pytest tests/unit/ -k frontend` green (this file + every pre-existing pin).
## Testing & Quality
- Unit: the pins above (each one names the regression it catches in its docstring — the house pattern).
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate stays green).
## Completion Criteria
- [ ] `tests/unit/test_frontend_doc_modal.py` green; every pre-existing frontend unit pin green (`uv run pytest tests/unit/ -k frontend`)
- [ ] the pin set catches: a second push on forward re-open, a `replaceState` on a non-modal entry, a double-registered popstate listener, a `history.back()`/`go()` introduction, a lost `lastPrev` write, a fallback-path push
- [ ] `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (this task is tests-only)
@@ -0,0 +1,29 @@
# Task 03 — the dedicated E2E: Back closes the modal, the history stays sane, Forward re-opens
**Phase:** `144_modal_back_button` · **Source:** `TODO.md:6` — "back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion"
**Story:** n/a
## Objective
The browser-visible proof with the REAL history: `page.goBack()` / `page.goForward()` drive the owner's exact scenario — back closes the modal (nothing else moves), a button close leaves no dead step, forward re-opens the document.
## Work
1. `tests/e2e/test_modal_back_button.py` — dedicated Playwright file, run in isolation, mock-only (the house E2E conventions — the per-module fixture import, the login helper, the chat-chip / sources-table open paths from the phase-26 story files):
- fixture: the standard E2E KB (at least one readable document); a helper `open_modal_via_chip(page)` — asks a question whose answer carries a source chip (the mock's citation echo), clicks the chip, waits for `#doc-modal` visible + the content rendered (the "Document loaded." desc state);
- test 1 (`test_back_closes_modal_view_unchanged`): boot on `/` (chat), `open_modal_via_chip`, note `page.url` (`/`) and the visible view (`#view-chat` not hidden) → `page.goBack()` → assert `#doc-modal` is hidden (closed), `page.url` STILL `/`, `#view-chat` still visible (the view behind was NOT navigated — the owner's confusion is gone) → one more `page.goBack()` is a REAL step: it leaves the app's shell history (in the test, assert the URL moved away from `/` or the page left the shell — the point: the second back did the navigation the first back should have done; if the test boots with no prior history, skip the second-back assert and note it — the modal entry's existence is proven by test 3's forward);
- test 2 (`test_button_close_leaves_no_dead_step`): boot, `open_modal_via_chip` → close via `#doc-modal-close` (the button affordance) → assert the modal hidden → assert the history depth is RESTORED: `page.evaluate("history.length")` equals the pre-open depth (the in-place `replaceState` removed the entry — the next Back is a real navigation step, not a silent no-op). (If the boot context's `history.length` baseline is flaky across Chromium versions, fall back to the OBSERVABLE: a `page.goBack()` after the button close leaves `/` immediately — one step, not two — and assert that instead; the depth assert is preferred when stable.);
- test 3 (`test_forward_reopens_same_document`): boot, `open_modal_via_chip` (document D) → `page.goBack()` (modal closes) → `page.goForward()` → assert `#doc-modal` visible again with D's title (the same document — `#doc-modal-title` text equals the pre-back title) and the history did NOT gain an entry (`history.length` unchanged by the re-open — the adopt, not a push);
- test 4 (`test_back_from_sources_keeps_drill` — the interplay guard): boot on `/sources.html` (the RAG view, admin — the login helper), open a document via a table link (the drill at the TOP level is the simplest case), `page.goBack()` → modal hidden, URL `/sources.html`, the Sources view still visible; if the fixture supports a drilled level (a folder with the phase-99 breadcrumb), open the document FROM a drilled level and assert the breadcrumb survived the back (the drill's popstate re-adopted its own state — no visible change).
2. Run the regression set in isolation (each its own run): this file; `tests/e2e/test_document_back_navigation.py` (the phase-13 FULL-PAGE /document.html back-nav — a different page, must stay green); the phase-99 Sources drill E2E (`test_kb_tree_table_and_back_nav.py` — the `kb` entry interplay must not leak).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Regression: the two named files green in isolation.
- Coverage: **>90%** on new/modified code (frontend — the `app/` gate unaffected).
## Completion Criteria
- [ ] back with the modal open → modal closed, URL unchanged, view unchanged (tests 1 + 4)
- [ ] button close → no dead history step (test 2)
- [ ] back → forward → the same document re-opens without a new history entry (test 3)
- [ ] `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` green in isolation; both regression E2Es green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the E2E is additive; no app/frontend change in this task)
@@ -0,0 +1,38 @@
# Phase 145 — the answer points at the original git source: the model links it, the modal shows it
**Source:** `TODO.md` L7 — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use. It's important to provide the full source so users get the complete answer at once rather than extracting it bit by bit with lots of questions."
**Story:** n/a (TODO-derived phase, Protocol B append).
**Context:** `app/rag/agent.py` — the read branch's result shapes (~L1630–1680: `Document {source}/{path}:\n` + `date: …\n` + image marker + content; the `E2E mock`'s `_READ_RESULT_PREFIX` = the FIRST line only — `tests/e2e/mock_llm.py` L1412 — so a new line BELOW the first line keeps the mock contract); `app/models.py` — `GitSource` (`url` L289, `kind` L292, `token` L316 — the PAT, never served, `branch` L333 — phase 135, NULL = default branch); `app/rag/git_sources.py` — `sanitize_url` (L~150: the phase-121 credential-free output mask — strips userinfo, byte-identical for clean URLs) and `clone_url_for` (the single credential-injection site); `app/schemas.py` — `DocContent` (the `GET /api/documents/content` payload — the phase-106 `created_at` field is the recent optional-metadata precedent) and `SourceRef` (UNCHANGED this phase — the done frame stays byte-identical, the origin rides the content payload instead); `app/api/docs.py` — the content endpoint (the `(source, path)` lookup, percent-encoding); `frontend/assets/document.js` — `renderDocument` (L172+: the shared meta-badge row — `metaBadge`/`pathCode` idioms — used by BOTH the doc modal and the full page); `app/rag/prompts.py` — the `<tools>` section (L162+: the `ls`/`read`/`grep` teaching, HIGH prompt only); `app/rag/retriever.py` — `source_ref_with_image` (untouched).
**Locked (owner-confirmed 2026-09-30, chat):** the model learns the origin from a new `origin:`/`branch:` line in read results (git sources only, credentials stripped) and is taught to include the original-source link in its answer — it builds the browse URL itself (no fragile server-side GitHub/Gitea URL construction); the doc modal + document page get the "Original" external link (one `renderDocument` site, fed by the content payload); local (non-git) sources get no link.
## Objective
An answer grounded in a git-sourced document comes with the way to the complete original in ONE step: the read result tells the model the repository origin (and pinned branch), the prompt teaches it to include the original-source link in its answer text (so it works in the UI AND through the API tokens), and the document modal + full page show the same origin as a visible "Original" external link.
## Dependencies
- `144_modal_back_button` (todo) — pipeline order only; code touch (the modal's meta row) is the same `renderDocument` surface, no ordering risk.
## Design (shared by all tasks — the executor reads this, not the chat)
- **The origin resolver (task 01):** `app/rag/git_sources.py` (the GitSource-domain home) — `origin_for_source(db, source_name) -> tuple[str, str | None] | None`: the source's directory basename (the importer's `source = root.name`) mapped to a `kind = "git"` row by the row's URL basename — the URL's path component, trailing `/` stripped, `.git` suffix stripped, last path segment (scp-style `git@host:owner/repo.git` → the part after `:` first; `sanitize_url` applied to the result so a legacy embedded-credential row never leaks; `branch` returned as-is — NULL means the default branch). EXACTLY ONE match → `(sanitized_url, branch)`; zero or TWO-OR-MORE matches (two repos with the same basename) → `None` (no line — never guess; the ambiguity is logged at WARNING with the sanitized names). No caching (the table is a handful of rows; a read's extra query is trivial).
- **The read result (task 01):** a git-sourced document's read result gains, IMMEDIATELY AFTER the `date:` line (before the image marker — the marker keeps its phase-122 position relative to the content): `origin: {sanitized_url}` and, when a branch is pinned, `branch: {branch}` — one line each, no URL for local sources, no lines at all for a source with no unambiguous git row (the result is byte-identical to pre-phase for every non-git doc — the existing pins pass unchanged; the mock's `_READ_RESULT_PREFIX` first-line contract is untouched — the new lines sit BELOW the header). The plain-read, truncated-plain, range-read, and truncated-range shapes all carry the lines at the same position (one insertion helper — the four result builders share it).
- **The teaching (task 02):** the `<tools>` section gains the citation-source rule (the `read` teaching's natural home — the model answers from what it READ): when an answer uses a document whose read result carries an `origin:` line, the answer INCLUDES the original source — a link to the repository (the `origin` line) with the document's path (from the `Document …` header) named, and the pinned branch when one is shown — formatted as the repository's file-view URL where the model can tell it (GitHub/Gitea/GitLab `…/blob/<branch>/<path>`), else the repo URL + path as text. The point (the owner's words, in the model's language): the user should get the complete source at once — one link, not a second question. Local-sourced documents carry no `origin` line — nothing to add. The `SUGGEST_INTRO` citation discipline (cite only what you read) is UNCHANGED — this rule extends the cited documents' answers, it does not widen the cite set.
- **The UI (task 03):** `DocContent` (the content payload) gains `origin_url: str | None = None` + `origin_branch: str | None = None` (the resolver's output for the document's source — NULL/absent for local sources, the `image_url`-style optional-metadata shape; a null field in the content payload is fine — the omission rule binds the SSE frames, not this payload). `renderDocument` (document.js L172+) — when `doc.origin_url` is present, the meta row gains, after the path code: an "Original" anchor (`<a>` — `createElement`, the never-build-HTML rule; `href = doc.origin_url`, `target="_blank" rel="noopener"` — the modal's "Full page" escape-hatch idiom; `textContent = doc.origin_url` VERBATIM — the copyable-link contract — in the `doc-path` ellipsis style, the full URL in a `title` attribute that also carries ` — branch: <name>` when `origin_branch` is present) — ONE site: the modal AND the full page show it (both call `renderDocument`).
- **The E2E (task 04):** a git row seeded over the fixture source's name (the resolver is name-based — a `kind="git"` row whose URL basename matches the fixture source name makes the fixture docs "git-sourced" for this phase's purposes), a scripted mock flow whose final answer carries the repo URL, and the modal's meta anchor asserted.
## Tasks
1. `01_origin_resolution.md` — the resolver + the read-result `origin:`/`branch:` lines (the four read shapes).
2. `02_prompt_teaching.md` — the `<tools>` original-source rule + the prompt pins.
3. `03_content_payload_render_link.md` — `DocContent` fields + the content endpoint + the `renderDocument` "Original" anchor (modal + full page).
4. `04_e2e_source_link.md` — the dedicated E2E (the answer carries the repo link; the modal shows the Original anchor; non-git docs stay byte-identical).
## Testing & Quality
- Unit: the resolver (the mapping, the sanitization, the ambiguity, the scp-form URL, the `.git` suffix, the no-row case); the read-result insertion (four shapes × {git, git+branch, local, ambiguous} — non-git byte-identical pins); the prompt copy pin; the payload/endpoint; the renderer (the anchor present/absent, the branch label, the ellipsis title).
- Integration: the content endpoint returns `origin_url` for a seeded git row (and null for a plain local source).
- E2E: one dedicated Playwright file, run in isolation, mock-only.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] a git-sourced read result carries `origin: <sanitized url>` (+ `branch:` when pinned); every local-source read result is byte-identical to pre-phase
- [ ] the HIGH prompt's `<tools>` section teaches the original-source link rule (pinned verbatim); the LOW prompt is byte-identical
- [ ] the doc modal AND /document.html show the "Original" external link for git-sourced docs (the branch in the label when pinned); nothing new for local docs
- [ ] `uv run pytest --cov=app --cov-report=term-missing` green, `app/` coverage >90%; `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` green in isolation; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the read header's first line, the mock's parse, the done frame, the LOW prompt, the non-git read results)
@@ -0,0 +1,29 @@
# Task 01 — the origin resolver + the read-result `origin:`/`branch:` lines
**Phase:** `145_git_source_links` · **Source:** `TODO.md:7` — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use…"
**Story:** n/a
## Objective
A read result for a git-sourced document tells the model where the complete original lives — the sanitized repository URL (and the pinned branch, when one exists) — so the model can link it in its answer (task 02's teaching). Every non-git read result is byte-identical to pre-phase.
## Work
1. `app/rag/git_sources.py` — `origin_for_source(db: Session, source_name: str) -> tuple[str, str | None] | None` beside `sanitize_url`:
- the candidate rows: `select(GitSource).where(GitSource.kind == "git")` (local rows are never origins — nothing to link to from the user's browser);
- the match: the row's URL basename == `source_name` — the URL's path component (after the `://`, or after `:` for scp-style `git@host:owner/repo`), trailing `/` stripped, a single trailing `.git` suffix stripped, last segment taken;
- EXACTLY one match → `(sanitize_url(row.url), row.branch)` — the phase-121 output mask keeps a legacy embedded-credential row's secret out of the prompt (the `clone_url_for` lesson: the stored URL may carry userinfo; the stored value is never modified);
- zero matches, or TWO-OR-MORE (two repos sharing a basename) → `None` + a WARNING log naming the sanitized candidates (never guess — the read result would otherwise carry a wrong origin into every answer);
- pure-DB helper (no git subprocess — `scripts/git_sync.py` stays the only git-invocation site, PLAN §13).
2. `app/rag/agent.py` — the read branch: after the doc resolves (all FOUR successful result builders — plain at-or-under-cap ~L1655, truncated plain ~L1636, range at-or-under-cap ~L1686, truncated range ~L1668), compute `origin = origin_for_source(db, doc.source)` ONCE per call (before the branching on ranged/truncated — the lines are identical in all four shapes) and insert IMMEDIATELY AFTER the `date:` line and BEFORE the image marker: the text-doc shape becomes `Document …: / date: / origin: / branch: / content`, the image-doc shape `Document …: / date: / origin: / branch: / marker / content` (the marker keeps its phase-122 position relative to the content — the origin lines ride above it). One line `origin: {url}` and, when `branch` is non-NULL, one line `branch: {branch}`. `origin is None` → NO lines (byte-identical to pre-phase). A shared tiny helper builds the two lines; the four result builders each call it once with the same strings.
3. `tests/unit/test_agent.py` (or the read-branch unit home) — the matrix: {git row matched, git row + pinned branch, local source (no git row), two rows same basename (ambiguous)} × the four read shapes → the exact result strings (the origin lines present at the exact position for the git cases, ABSENT for local/ambiguous); the first line of every result is still `Document {source}/{path}:` (the mock's `_READ_RESULT_PREFIX` contract — assert it); the image-doc shape keeps its marker position (the phase-122 pin).
4. `tests/unit/` — the resolver's own unit file (the git_sources unit home — `tests/unit/test_git_sources*.py` if it exists, else a new one): the basename mapping (`.git` suffix, trailing `/`, scp-style `git@host:owner/repo.git`, an `@` inside a PATH — not userinfo — stays), the `sanitize_url` application (a legacy `https://user:tok@host/x.git` row → the prompt line is `https://host/x.git`), the zero-match and two-match `None` + the WARNING (caplog), the `branch` passthrough (NULL → None → no `branch:` line).
## Testing & Quality
- Unit: the resolver matrix + the four-shape read-result matrix (above); every pre-existing read-branch pin passes UNCHANGED (non-git byte-identity is the point).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] a git-sourced read result carries `origin: <sanitized url>` (+ `branch: <name>` when pinned) in all four read shapes, at the documented position; the first line is untouched
- [ ] local-source and ambiguous-basename read results are byte-identical to pre-phase (the existing pins prove it)
- [ ] no credential ever reaches a read result (the legacy embedded-token case is pinned sanitized)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed work (the grep/ls surfaces, the refusal lines, the `read_docs`/`read_truncations` signals, the SSE frames)
@@ -0,0 +1,30 @@
# Task 02 — the `<tools>` teaching: the answer includes the link to the original source
**Phase:** `145_git_source_links` · **Source:** `TODO.md:7` — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use. It's important to provide the full source so users get the complete answer at once rather than extracting it bit by bit with lots of questions."
**Story:** n/a
## Objective
The model that answers from a git-sourced document includes, in the answer text, the link to the complete original — repository (its read result's `origin` line), the file's path (the `Document …` header), and the pinned branch when one was shown — so the user gets the source in one step, in the UI AND through the API tokens (the answer is the only surface both share).
## Work
1. `app/rag/prompts.py` — `TOOLS_SECTION` (L216+): append the original-source rule to the `read` clause (it is the read's contract — the `origin:` line is a read-result fact, and the rule binds the ANSWER that uses what the read added). Locked copy (pinned verbatim by the tests):
`A read result for a document from a git source carries an origin line (and a branch line when a branch is pinned): when your answer uses such a document, include the original source in the answer — a link to the repository from the origin line, with the document's path (from the Document header) named and the pinned branch when one is shown — as the repository's file-view URL where you can tell it (for example <repo-url>/blob/<branch>/<path>), or the repository URL plus the path as plain text otherwise. The user should be able to open the complete original from your answer in one step, not ask again for the source.`
Position: after the truncation workflow sentence (the `read` clause's end), BEFORE the `grep` clause begins — one appended sentence-group, the rest of the section byte-identical. The rule is CONDITIONAL on the `origin` line's presence — a local-sourced document's read result carries none, so the rule adds nothing to those answers (the model has no URL to cite; it must NOT invent one — the sentence's conditioning does that job; if a live run ever shows a fabricated origin, the phase-72-style teaching refusal is the follow-up, not this phase).
2. The `AGENT_TOOLS` `read` description (app/rag/agent.py L331+ — the most local text at call time, the phase-70 contract) gets the ONE-clause mirror: "For a document from a git source, the result also carries an `origin` line (and `branch` when pinned) — include that original source in your answer (see your tools instructions)." (the behavior detail stays in `TOOLS_SECTION` — the description points, the section teaches; the phase-124 `show_image` description's "see the instructions" shape is the precedent if the executor prefers a shorter pointer).
3. `tests/unit/` — the prompt pins (the HIGH-prompt suite that pins `TOOLS_SECTION`):
- the new rule pinned verbatim (the exact sentence-group, at its exact position — between the truncation sentence and the `grep` clause),
- the section's OTHER bytes byte-identical (a diff-style pin: the pre-phase section with ONLY the new sentence-group inserted),
- the LOW/deflect prompt stays byte-identical (the `<tools>` section is HIGH-only — the existing pin passes unchanged),
- the mock is untouched (it keys off the `<tools>` marker's presence — assert the marker line is unchanged),
- the `read` tool description pin updated with the new clause (the `AGENT_TOOLS` schema pins).
4. `SUGGEST_INTRO` (L152) and the citation discipline are UNCHANGED (the rule extends the answers of cited documents; it does not widen the cite set — the "cite only what you read" sentence passes byte-identical).
## Testing & Quality
- Unit: the pins above; every pre-existing prompt pin passes (the LOW prompt, the section order, the `DEFLECT_MODE` marker, the `SUGGEST_INTRO` copy).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] the HIGH prompt's `read` clause carries the original-source rule (pinned verbatim, exact position); the tool description carries its pointer clause
- [ ] the LOW prompt is byte-identical; the mock's marker keying is untouched
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the section order, the deflect path, the citation discipline, the other tool clauses)
@@ -0,0 +1,26 @@
# Task 03 — the "Original" link: `DocContent` origin fields + the shared `renderDocument` anchor
**Phase:** `145_git_source_links` · **Source:** `TODO.md:7` — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use…"
**Story:** n/a
## Objective
Every surface that shows a document (the chat's doc modal AND the full /document.html page — one `renderDocument` site) shows the git origin as a visible, copyable "Original" external link — fed by the content endpoint's new origin fields. Non-git documents show nothing new (byte-identical meta row).
## Work
1. `app/schemas.py` — `DocContent` (L531+): `origin_url: str | None = None` and `origin_branch: str | None = None` — the `image_url`-style optional metadata (L557+): ABSENT from the wire when `None` (the `_drop_absent_share_url` omission precedent — the serializer drop, never `null`), so a non-git document's payload is byte-identical to pre-phase. `origin_url` is the sanitized clone URL (credential-free — the resolver's output, task 01's `sanitize_url` guarantee); `origin_branch` is the pinned branch (absent when the row pins none — NULL = the repo's default branch, nothing to show).
2. `app/api/docs.py` — `get_document_content` (L165+): after the document resolves, `origin = origin_for_source(db, doc.source)` (task 01's resolver — the SAME function the read branch uses, one mapping for the whole app) → set the two payload fields (`None` → absent on the wire). The endpoint's percent-encoding, 404, and image bytes are untouched.
3. `frontend/assets/document.js` — `renderDocument` (L172+): when `doc.origin_url` is present, the meta row gains, AFTER the path `code` and BEFORE the Created badge: an "Original" anchor — `createElement("a")` (the never-build-HTML rule), `className "doc-origin"` (a new class in `styles.css` mirroring the `doc-path` code style — ellipsis clip, the house dark-tech palette tokens only, `target=_blank` affordance color from the existing link tokens), `href = doc.origin_url`, `target="_blank"`, `rel="noopener"` (the modal's "Full page" escape-hatch idiom — index.html L1579), `textContent = doc.origin_url` — the full URL VERBATIM as the visible text (the copyable-link contract: no decorative label inside the anchor text — a ` (branch)` suffix would break copy-paste; the CSS ellipsis clips the display, and the `title` attribute carries the full URL, the `pathCode` precision idiom — plus, when `doc.origin_branch` is present, ` — branch: <name>` appended: `title = doc.origin_url + " — branch: " + doc.origin_branch`). `doc.origin_url` absent (the field simply missing on the wire for non-git docs) → the meta row is byte-identical to pre-phase (the badge row unchanged — the existing `renderDocument` pins pass as-is).
4. `frontend/assets/styles.css` — the `.doc-origin` class (the `doc-path` idiom: `code`-family font, ellipsis `white-space: nowrap; overflow: hidden; text-overflow: ellipsis`, the ≤640px row behavior mirroring the badge row's wrap rules — the meta row already wraps on mobile, the anchor is one more inline item).
5. `tests/unit/test_document_viewer.py` (the `renderDocument` pin home — or its neighbor per the existing layout) — the anchor: present + correct `href`/`target`/`rel`/`text`/`title` for a git doc (branch and no-branch variants); ABSENT (the meta row's exact pre-phase children) for a doc without `origin_url`; the never-build-HTML shape (no innerHTML/outerHTML in the new code).
6. `tests/integration/` — the content endpoint: a `kind="git"` row seeded with a URL basename matching a fixture source → `GET /api/documents/content` carries `origin_url` (+ `origin_branch` for a pinned row); a plain local fixture source → the field is ABSENT from the JSON (not `null` — the omission pinned on the wire).
## Testing & Quality
- Unit: the renderer pins (above) + the endpoint integration (above); every pre-existing `renderDocument` / content-endpoint pin passes unchanged (the non-git byte-identity).
- Coverage: **>90%** on this task's new/modified code (`app/`).
## Completion Criteria
- [ ] the doc modal AND /document.html show the "Original" anchor (repo URL as text, `_blank` + `noopener`, the branch in the `title` when pinned) for git-sourced documents
- [ ] a non-git document's content payload and meta row are byte-identical to pre-phase (the field absent on the wire, the badge row unchanged)
- [ ] no credential in the payload (a legacy embedded-token URL is pinned sanitized — the resolver's guarantee, end-to-end)
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the viewer's summary panel, the date editor, the image block, the done frame, the SSE contract)
@@ -0,0 +1,33 @@
# Task 04 — the dedicated E2E: the answer carries the repo link, the modal shows the Original anchor
**Phase:** `145_git_source_links` · **Source:** `TODO.md:7` — "there always needs to be a link to the original git source if possible. I've asked a few questions like 'how does the phased execution process work for pi.dev?' And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use. It's important to provide the full source so users get the complete answer at once rather than extracting it bit by bit with lots of questions."
**Story:** n/a
## Objective
The browser-visible proof of the owner's scenario: a question about a git-sourced document produces (1) an answer that contains the repository link (the model-side teaching, through the real read result that carries the `origin:` line) and (2) a document modal whose meta row carries the "Original" external anchor. A local-sourced document produces neither (the byte-identity guard).
## Work
1. `tests/e2e/mock_llm.py` — ONE scripted flow, house style (module-header docstring entry):
- marker: the user message contains `original source link`;
- request 1: a `read` tool call for the fixture document (the flow's doc — its `origin:` line is added by the REAL server when the seeded git row matches — the mock does not fabricate it; the flow's request-2 keying must tolerate the extra line(s) between the `date:` line and the content — the mock's read-result parsing keys off the FIRST line (`_READ_RESULT_PREFIX`, L1412), so the new lines need no mock change; if the flow's echo shape copies a fixed slice of the result, the executor verifies the echo still lands on content, not the origin line, and adjusts the EOOCH slice if it does);
- request 2 (the read result present): the final answer — a DETERMINISTIC string containing the seeded repository URL verbatim (e.g. `The full source lives at https://example.com/reese/skills — see the repo for the complete original.` — the mock is scripted, so the "model includes the link" behavior is asserted on the scripted echo, while the SERVER-side half — the `origin:` line actually reaching the mock — is asserted on the request the server SENT: the E2E inspects nothing of the prompt directly (the mock records nothing), so the origin-line assertion goes through the read RESULT's surface: the SSE `tool_result`/tool frame shape the UI renders for the read — the phase-117 compact tool surface — which carries the result text; assert the rendered tool output contains `origin: https://example.com/reese/skills`).
2. `tests/e2e/test_git_source_links.py` — dedicated Playwright file, run in isolation, mock-only (the house conventions — the per-module fixture import + the git-source row seeding in the test's own DB session, the login helper, the SSE frame collection from the chat story files):
- fixture seeding: the standard E2E KB (one source, one readable doc) PLUS a `GitSource` row — `kind="git"`, `url="https://example.com/reese/skills"` (public form — no credential), `branch=None` for test 1 and `branch="main"` for the pinned-branch test — whose URL basename (`skills` after the `.git`-less path) MATCHES the fixture source's name (the resolver's name mapping — the fixture source is locally imported, but the mapping is name-based, which is the point of the seam); a SECOND plain local source (no git row) is the byte-identity control;
- test 1 (`test_answer_carries_origin_link`): ask the `original source link` question on the git-matched source's doc → collect the SSE → assert the rendered tool output for the `read` carries `origin: https://example.com/reese/skills` (the server put the line in the result the model saw) and the final `done` answer contains the repository URL (the scripted echo — the model-side contract, deterministic);
- test 2 (`test_modal_shows_original_anchor`): same setup → click the source chip → `#doc-modal` rendered → the meta row contains the `.doc-origin` anchor with `href="https://example.com/reese/skills"`, `target="_blank"`, `rel="noopener"`, `textContent` = the URL verbatim (and NO branch in the text — `branch=None`);
- test 3 (`test_pinned_branch_in_title`): the `branch="main"` row → the same anchor's `title` is `https://example.com/reese/skills — branch: main`;
- test 4 (`test_local_source_has_no_origin`): the control source's doc → the read's rendered tool output carries NO `origin:` line (byte-identical result shape) and the modal's meta row has NO `.doc-origin` anchor (the pre-phase badge row).
3. Run the regression set in isolation: this file + `tests/e2e/test_read_line_ranges.py` (the read-result shape contract through the mock) + `tests/e2e/test_document_viewer.py` (the `renderDocument` meta row).
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` — green IN ISOLATION (DB up: `podman compose up -d db`), mock-only.
- Regression: the two named files green in isolation.
- Coverage: **>90%** on new/modified code (`app/`).
## Completion Criteria
- [ ] the read's tool output carries the sanitized `origin:` line (git-matched source) and the done answer carries the repository URL (tests 1–3)
- [ ] the modal's meta row shows the "Original" anchor (URL verbatim text, `_blank`+`noopener`, the pinned branch in the `title` — tests 2–3)
- [ ] the local-source control is byte-identical: no `origin:` line in the read result, no anchor in the meta row (test 4)
- [ ] `uv run pytest tests/e2e/test_git_source_links.py -v --no-cov` green in isolation; the regression E2Es green in isolation
- [ ] `uv run pytest --cov=app --cov-report=term-missing` — `app/` coverage >90%; `uv run ruff check . && uv run pyright` clean
- [ ] no behavior change in completed phases (the mock's first-line parsing, the done frame, the local-source payloads/results)
-6
View File
@@ -1,7 +1 @@
# TODO
- [ ] From a chat I was having, the LLM is trying to read different ranges of a document and keeps saying: "The system is telling me the document is already in my context, but I haven't actually seen the full text of the Hail of Thorns spell entry. The read calls keep returning "Already in your context" which means the text was loaded at some point, but I need to find where it actually is. Let me look at the grep results again." This seems like a critical bug, fix it.
- [ ] The agent is really struggling with questions like "Summarize the last 10 phases of brain of Reese". When I asked that to the current deployment it said "I'll pull the details for the last 10 phases (104–113)." when it clearly had phase 137 loaded in the RAG. When I asked brain of reese what went wrong it responded with "The <knowledge_base> section in the system prompt only generated summaries up to phase 113 at import time. The phases 128–137 exist as full markdown files in brain-of-reese/.agents/phases/complete/, but their summaries weren't included in the initial KB index. This is a KB sync/generation gap, not a retrieval logic bug. If you want, I can grep or read any of these files directly whenever you need them!"
- [ ] mobile keyboard pops back up after answer is complete and that's annoying
- [ ] back button doesn't close the modal of you're looking at but instead navigates back behind the modal which can lead to confusion
- [ ] there always needs to be a link to the original git source if possible. I've asked a few questions like "how does the phased execution process work for pi.dev?" And the correct answer is an explanation and then point to the skills repo in the answer so I can go download the skills for my personal use. It's important to provide the full source so users get the complete answer at once rather than extracting it bit by bit with lots of questions.