phase: 141_read_range_dedupe_fix
All gates green and verified. Final report: **Phase 141 final verification pass — complete** - **Verified (tasks 01–04 already in `complete/`):** `AgentHolder.read_coverage` span recording (all 4 read outcomes, refusals record nothing); coverage dedupe matrix (range read of uncovered lines proceeds, fully-covered re-read → `ALREADY_IN_CONTEXT` with a now-true claim, partial plain re-read → `REREAD_PARTIAL` naming merged spans, `REREAD_TRUNCATED` retired, fast path retired per task 02); `TOOLS_SECTION` re-read sentence re-cut to coverage-true form (prompt lock re-pinned); owner-scenario integration repro (pure + truncated variants) and mock `read two ranges` flow + dedicated E2E — all match the task specs exactly; no code defects found. - **Tests/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → exit 0, TOTAL 99% (5498 stmts, `app/rag/agent.py` 99%) — run twice, both green; `uv run pytest tests/e2e/test_read_range_dedupe.py -v --no-cov` → 2 passed in isolation; `uv run ruff check . && uv run pyright` → All checks passed / 0 errors. - **Completion criteria:** (1) two different ranges both reach the model, re-covered re-read refused truthfully — ✅ (integration + E2E); (2) partial plain re-read gets the `offset`/`limit` teaching, false claim gone both directions — ✅; (3) plain-read + phase-131 result shapes byte-identical, existing pins pass unchanged — ✅; (4) all gate commands green, >90% — ✅; (5) no behavior change in completed phases (grep/ls, truncation notice, `read_docs`/`read_truncations` signals) — ✅ (whole project suite green). - **Documentation:** README.md — fixed pre-existing phase-140 gap: `BOR_PDF_PICTORIAL` / `BOR_PDF_PICTORIAL_MIN_CHARS` were in `.env.example`/Settings but undocumented (README claims every `.env.example` var is covered); added the pictorial-PDF-pass bullet to the PDF section + two config-reference rows. AGENTS.md — verified consistent (rules, quick-ref commands, phase refs all check out) — no changes needed. - **Notable:** overview's "fast path survives for holder-state rows" note is superseded by task 02's authoritative decision (every dedupe row resolves — "full" needs the true line count); implementation follows task 02. No phase files touched; changes left in the working tree for the harness commit. - **Next pending phase:** `142_kb_overview_freshness`.
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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
|
||||
@@ -1,26 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user