chore(agent): phase roadmap from TODO.md (phases 73-75), clear the file
Convert the two unchecked TODO items into executable phases (Protocol B, appended after the 72 completed phases): - 73_hidden_tab_stream (TODO L3): a merely-hidden browser tab must never stop a generating answer; repro/root-cause decision tree + the pagehide partial-correlation fix + the hidden-tab E2E pin. - 74_llm_chat_history (TODO L4, history): client-provided history in POST /api/chat (stateless, A10) mapped through both the deflected and grounded agent paths, prior thinking blocks preserved via reasoning_content, capped oldest-first; mock echo marker + E2E. - 75_save_doc_full_session (TODO L4, save-as-doc): the Save-as-doc draft body becomes the full session transcript; edit-out happens in the existing doc-edit body; multi-turn git-verified E2E. Owner-confirmed assumptions A1-A7 are recorded as ASSUMPTION lines in the task files. TODO.md is cleared (items now live in .agents/phases/todo/).
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# Phase 73 — Hidden tab never stops a generating answer
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L3 — "Clicking on another tab while an answer is generating stops that answer from being generated. Reponses should continue to generate unless you outright close the tab."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
**Context:** `frontend/assets/app.js` (the SSE turn machine: `runTurn` ~L1859, the `pagehide` partial-persist handler ~L2162, the 120s pre-token guard `TURN_TIMEOUT_MS` ~L287 / `armTurnTimeout` ~L982, `readSSE` ~L1044, the settle paths `done` ~L2011 / stop ~L2079), `app/api/chat.py` (the `finally` "turn cancelled" log line — the server-side signal that the SSE consumer really went away), phase-48 teardown contract (a REAL consumer departure — tab closed, navigation, Stop — still cancels the fetch and stops the model: that behavior is correct and must survive this phase).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
An in-flight answer keeps generating while the browser tab is merely hidden (switched away from) and completes when the user returns; only closing the tab, navigating away, or clicking Stop aborts the turn. Also fixes the latent record-corruption on that path (a `pagehide` partial persist can leave a duplicated brain turn in the saved conversation, which makes the answer *look* truncated on restore).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
— (none)
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_repro_root_cause.md` — bounded repro with an instrumentation decision tree; pin down WHICH mechanism stops the answer on tab switch (no permanent code changes).
|
||||||
|
2. `02_fix_hidden_tab.md` — the fix: correlate the pagehide partial with the turn's settle so `done`/stop *replaces* it (never appends a second brain turn); if the repro implicates the 120s guard, make hidden time not count toward it; keep phase-48 teardown for real departures.
|
||||||
|
3. `03_e2e_hidden_tab_stream.md` — Playwright regression: synthetic `pagehide` mid-stream → the answer completes exactly once, the record has one brain turn, reload restores it; regressions + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: frontend-only phase — no `app/` changes expected (coverage floor unaffected, must stay **>90%** on `app/`).
|
||||||
|
- E2E: new story suite `tests/e2e/test_hidden_tab_stream.py`, run in isolation (`uv run pytest tests/e2e/test_hidden_tab_stream.py -v --no-cov`) against the deterministic mock LLM (long/slow deterministic streams give a guaranteed mid-stream window).
|
||||||
|
- Regression runs in isolation: `test_chat_rag.py`, `test_chat_persistence.py`, `test_chat_history.py` (phase 50), `test_stop_generation.py` (phase 48 contract: real Stop/cancel still tears down), `test_retry_answer.py`.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The repro's root cause is named in the phase-73 commit message body (one line: which candidate from task 01 fired, or "none of C1–C3 — <finding>").
|
||||||
|
- [ ] `uv run pytest tests/e2e/test_hidden_tab_stream.py -v --no-cov` green in isolation: a tab switch (synthetic `pagehide`) mid-turn never stops the answer, and the persisted conversation holds exactly one brain turn for that question.
|
||||||
|
- [ ] Real departures unchanged: Stop button, tab close, and navigation still cancel the fetch (phase-48 `test_stop_generation.py` + `test_chat_persistence.py` green).
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` Conventional-Commits commit (e.g. `fix(chat): keep generating while the tab is hidden`); phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Task 01 — Repro + root-cause decision tree (no permanent code changes)
|
||||||
|
|
||||||
|
**Phase:** `73_hidden_tab_stream` · **Source:** `TODO.md:3` — "Clicking on another tab while an answer is generating stops that answer from being generated. Reponses should continue to generate unless you outright close the tab."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Pin down the mechanism that stops a generating answer when the user switches to another browser tab, with concrete evidence (client events, network lifecycle, server log) — so task 02 fixes the right thing instead of guessing.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. Manual repro (dev server `uv run uvicorn app.main:app --reload` + a real Chromium/Firefox with DevTools; use a question that streams for 30–60s+ so the window is easy to hit): start the answer, click ANOTHER browser tab for 30–120s, return. Record: does the bubble keep growing while hidden? Does it complete on return? Screenshot each state.
|
||||||
|
2. Client instrumentation (DevTools console — temporary, all removed before commit): log with timestamps, while switching tabs mid-turn,
|
||||||
|
- `window` `pagehide` / `pageshow` / `visibilitychange` (capture `event.persisted` and `document.visibilityState`),
|
||||||
|
- each SSE frame arrival inside `readSSE` (`frontend/assets/app.js` ~L1044) vs `document.visibilityState` at arrival (do frames keep arriving while hidden?),
|
||||||
|
- whether the error copy "That's taking a long time — the answer may be stuck." (`TURN_TIMEOUT_MS` guard, `armTurnTimeout` ~L982) or "The stream ended before my answer finished — try again?" (stream-drop guard ~L2060) appears.
|
||||||
|
3. Server evidence: watch the uvicorn log for the phase-48 line `chat: turn cancelled question=… total_ms=…` (`app/api/chat.py`, the stream `finally`). That line proves the SSE connection was really torn down (browser or proxy cancelled it); its ABSENCE while the tab is merely hidden proves the client JS side stopped consuming/accepting the frames.
|
||||||
|
4. Decision tree — record which fired:
|
||||||
|
- **C1 — `pagehide` on tab switch:** the browser fires `pagehide` when a tab is merely hidden (spec-compliant behavior; the app's handler at `frontend/assets/app.js` ~L2162 then persists the partial through `rememberBrainTurn`). When `done` later appends the full answer (~L2041), the `conversation` record (`bor.chat.v1` in localStorage) holds TWO brain turns for the one question — the partial AND the full answer. Restore/share renders both; the saved chat (`saved_chats.messages`) is corrupted the same way. This is the corruption branch task 02 fixes unconditionally.
|
||||||
|
- **C2 — 120s guard while hidden:** the pre-token `TURN_TIMEOUT_MS` timer fired during the hidden window (only possible before the first thinking/delta/retry frame, which is when it clears — background timer throttling can delay it past the moment the first token would have arrived). The catch reads the guard's abort as "stuck" and errors the turn.
|
||||||
|
- **C3 — real connection cancel:** the network panel shows `POST /api/chat` cancelled (or the server logged `turn cancelled`) while the tab was merely hidden → the browser (or an intermediate proxy, e.g. a `proxy_read_timeout` on a deployment path) dropped the connection. On desktop Chrome/Firefox this should NOT happen; if it does only through a specific proxy, record that and keep the app-side fix to C1/C2 (deployment fixes are out of scope — name the proxy in the commit message).
|
||||||
|
- **C4 — none of the above:** document exactly what was observed (frames stopped arriving while hidden with no error frames, etc.) in the commit message; task 02 then applies the C1 hardening + the E2E pin only.
|
||||||
|
5. Leave the repo clean: instrumentation removed, `git status` shows no source changes from this task (only the phase-dir planning files, which task 03 commits together with the fix).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No permanent code in this task — `uv run pytest` must stay green unchanged (the gate is still run).
|
||||||
|
- Evidence to keep: the identified candidate (C1/C2/C3/C4 + one-line rationale) — it goes into the phase-73 commit message body (written in task 03).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Repro performed in a real browser with DevTools; the event/frame/log evidence above is captured (screenshots may live in `.agents/screenshots/`).
|
||||||
|
- [ ] One candidate (C1–C4) is identified with its evidence; C1's double-record path (if `pagehide` fires on tab switch in the test browser) is demonstrated by inspecting the `bor.chat.v1` record after a tab switch + completion.
|
||||||
|
- [ ] No permanent code changes; full suite green.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Task 02 — Fix: a hidden tab never stops a turn; the pagehide partial is replaced, never duplicated
|
||||||
|
|
||||||
|
**Phase:** `73_hidden_tab_stream` · **Source:** `TODO.md:3` — "Clicking on another tab while an answer is generating stops that answer from being generated. Reponses should continue to generate unless you outright close the tab."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Make the client survive a merely-hidden tab: the in-flight stream keeps filling the live bubble and, when the turn settles, the `pagehide` partial is *replaced in place* by the final record — so `conversation` (and the auto-saved `saved_chats` row) holds exactly one brain turn per question, whatever events the browser fired while the tab was away.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/app.js` — correlate the pagehide partial with the turn's settle (the C1 hardening; applies unconditionally, it is a real latent bug):
|
||||||
|
- Add one module-scope variable next to the other phase-20/48 turn locals (`persistedOnLeave` ~L952 area): `leavePartialIndex = -1` — the index in `conversation` of the brain record the `pagehide` handler pushed for THIS turn, else `-1`.
|
||||||
|
- In the `pagehide` handler (~L2162): after `rememberBrainTurn(acc, { thinking: thinkingAcc || undefined })` runs, set `leavePartialIndex = conversation.length - 1` (the record it just pushed). Reset `leavePartialIndex = -1` at the top of `runTurn` (turn-local, like the other locals).
|
||||||
|
- In the `done` settle (~L2041) and in the stop-path settle (~L2079, where the partial is persisted with the `stopped` marker): if `leavePartialIndex >= 0` AND `conversation[leavePartialIndex]?.who === "brain"` AND the turn is settling with its OWN final record, REPLACE that entry in place (`conversation[leavePartialIndex] = { …settledRecord }`) instead of `push`ing a second brain record. `rememberBrainTurn` currently pushes — either add an optional in-place mode to it or inline the replace at the two settle sites; keep `saveConversation()` + `persistConversation()` running on the replaced record (the auto-save refreshes the row exactly once).
|
||||||
|
- Invariant to keep: a REAL navigation (page actually unloads) never runs a settle, so the partial stays persisted exactly as today — the leave-save behavior is unchanged.
|
||||||
|
- Identity guard: the replace only fires when the recorded index still points at a brain record — a New-Chat click or restore between pagehide and settle (impossible today, but the guard makes the invariant explicit) falls back to the append.
|
||||||
|
2. `frontend/assets/app.js` — ONLY IF task 01 identified C2 (guard fired during a hidden pre-token window): make hidden time not count toward `TURN_TIMEOUT_MS`. On `document.addEventListener("visibilitychange", …)`: when the page becomes visible again and `turnTimeout` is still armed (the pre-token window — it is cleared on the first thinking/delta/retry frame), re-arm it with a fresh `TURN_TIMEOUT_MS` (so only visible pre-token time counts). Skip this step entirely if C2 did not fire — the guard's behavior is owner-locked phase-17/48 territory and must not move without the evidence.
|
||||||
|
3. `frontend/assets/app.js` — do NOT touch: the Stop-button abort (`turnAbort.abort()`), `cancelStream(res)`, the phase-48 teardown contract (real tab close / navigation still cancels the fetch and the server's `chat_stream` teardown still stops the model — that is the TODO's "unless you outright close the tab" and it already works).
|
||||||
|
4. - ASSUMPTION A1 (owner-confirmed 2026-09-08): the fix scope is client-side — a merely-HIDDEN tab must never stop a turn; only tab close, real navigation, or the Stop button abort. The pagehide partial-persist stays for real navigations/bfcache but is correlated so the settle replaces it. No server changes in this phase unless task 01's C3 evidence names a server defect (if so, record it; deployment/proxy tuning is out of scope).
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Frontend-only: no `app/` logic changes — the >90% `app/` coverage floor is unaffected (still run the full suite).
|
||||||
|
- The behavioral pin lands in task 03's E2E (there is no JS unit-test infra in this repo — Playwright is the frontend gate).
|
||||||
|
- Manual re-verify against the task-01 repro steps in the real browser (switch tabs mid-stream → answer completes; `bor.chat.v1` shows one brain turn; reload → one bubble).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `conversation` can never hold two brain records for one question via the pagehide→settle path (read the code path: the replace is the only write after a pagehide partial; the identity guard is present).
|
||||||
|
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] Real-departure behavior unchanged (Stop / close / navigation still cancel — verified by the phase-48 and persistence E2E suites in task 03).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Task 03 — E2E: synthetic pagehide mid-stream → answer completes exactly once + regressions + commit
|
||||||
|
|
||||||
|
**Phase:** `73_hidden_tab_stream` · **Source:** `TODO.md:3` — "Clicking on another tab while an answer is generating stops that answer from being generated. Reponses should continue to generate unless you outright close the tab."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Pin the phase-73 behavior in a deterministic Playwright suite (real tab-switching is browser-environment-specific; a dispatched `pagehide` + hidden `visibilityState` exercises exactly the code path the browsers that fire it on tab switch take), run the regression suites, and commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_hidden_tab_stream.py` (Playwright; the app-boot + mock-LLM pattern from `tests/e2e/conftest.py` / `tests/e2e/test_response_to_docs.py`):
|
||||||
|
- Slow deterministic stream for a guaranteed mid-stream window: ask the mock's `write a long answer` question (on-topic phrasing so the gate is HIGH — ~8s stream, ends in `LONG-ANSWER-END`) — or `think out loud then hesitate` (4s pre-content pause) for the pre-token variant.
|
||||||
|
- `test_hidden_tab_does_not_stop_the_answer` — send the question; wait until answer text is visibly streaming (a few delta frames rendered); then, mid-stream, from the page:
|
||||||
|
```js
|
||||||
|
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" });
|
||||||
|
window.dispatchEvent(new PageTransitionEvent("pagehide", { persisted: false }));
|
||||||
|
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
|
||||||
|
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false }));
|
||||||
|
```
|
||||||
|
(the exact event sequence the tab-switching browsers deliver — and, if task 01's C2 fix landed, this also covers the visible-return re-arm); wait for `done`. Assert: the bubble contains the FULL mock answer including `LONG-ANSWER-END`; no error banner ("The stream ended before my answer finished…", "That's taking a long time…"); the `bor.chat.v1` localStorage record has EXACTLY ONE brain turn for the question (no duplicated partial) and its `text` equals the full answer; the auto-saved row (admin context, `persistConversation` path) carries the same single brain turn if reachable — otherwise assert on the localStorage record (the shared shape).
|
||||||
|
- `test_reload_after_hidden_tab_restores_one_bubble` — same setup, but after `done`, reload the page: the restored conversation renders exactly one brain bubble for the question (the restore path re-renders the `bor.chat.v1` record).
|
||||||
|
- `test_baseline_no_pagehide_still_completes` — the same long question with NO dispatched events completes identically (guards against an over-eager fix changing the normal path).
|
||||||
|
- If task 01 identified C2 and the re-arm landed: `test_pre_token_guard_survives_hidden_window` — `think out loud then hesitate` question (pure pre-token pause), dispatch the same hidden/`pagehide`/visible sequence DURING the pause, assert the turn still settles with the answer (no "taking a long time" error). If C2 did NOT fire, omit this test with a one-line header comment saying so.
|
||||||
|
2. Regression runs (isolation, per AGENTS.md rule 9): `uv run pytest tests/e2e/test_chat_rag.py -v --no-cov`, `test_chat_persistence.py` (pagehide partial-persist on real navigation — the unchanged behavior), `test_chat_history.py` (phase-50 saved chats), `test_stop_generation.py` (phase-48 Stop/teardown contract), `test_retry_answer.py`.
|
||||||
|
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
4. Commit (Conventional Commits, `--no-gpg-sign`) — e.g. `fix(chat): keep generating while the tab is hidden` with the task-01 root-cause line (C1–C4 finding) in the message body; move `.agents/phases/todo/73_hidden_tab_stream/` → `.agents/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_hidden_tab_stream.py -v --no-cov` green in isolation (DB up; mock LLM).
|
||||||
|
- Coverage: **>90%** on `app/` (no `app/` changes expected — the floor holds by the full suite).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The three (or four) E2E tests above pass in isolation; the double-brain-turn corruption is pinned (exactly-one-brain-turn assertion).
|
||||||
|
- [ ] Regression suites green in isolation.
|
||||||
|
- [ ] Full suite + coverage >90% + ruff + pyright clean.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit carrying the root-cause line; phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Phase 74 — Chat history (with prior thinking) reaches the LLM
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L4 — "Chat history isn't being passed to the LLM. When the LLM responds and you ask a follow-up question the previous question/answer isn't passed to the model. Since my models support preserve thinking, make sure to pass previous thinking blocks as well. Then, update the \"save as doc\" process to include the output from the entire chat session rather than the last response. The user can edit out anything they don't want to keep from previous replies." (this phase covers the first three sentences; the "save as doc" sentences are phase 75)
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
**Context:** `app/api/chat.py` (today `messages = [system, user]` only — the deflected path at ~L381 and the grounded `run_agent` call at ~L414), `app/rag/agent.py` (`run_agent` builds its own `[system, user]` at ~L863), `app/rag/llm.py` (`chat_stream`/`chat`/`chat_stream_retried` take `list[dict[str, str]]`; the wire already carries prior-model reasoning as `reasoning_content` deltas), `app/schemas.py` (`ChatRequest`), `app/config.py` (`BOR_` settings), `frontend/assets/app.js` (`runTurn` ~L1859 is the SINGLE funnel for send / retry-reask / stale-regen; the `conversation` array is the `bor.chat.v1` record whose brain entries already carry the `thinking` key since phase 17), `tests/e2e/mock_llm.py` (the deterministic marker-trigger convention).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
A follow-up question reaches the model together with the conversation so far — every prior user/brain turn, and the prior thinking blocks on brain turns (preserve-thinking, via the endpoint's existing `reasoning_content` convention) — while `/api/chat` stays stateless: the client provides the history in the request body and the server stores nothing new (owner-locked A10).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `73_hidden_tab_stream` (todo) — keeps the chat turn path green first (its E2E pins the stream/record lifecycle this phase's client change touches).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_server_history_ingestion.md` — `ChatRequest.history` schema + caps settings + the history→messages mapper (assistant turns carry `reasoning_content`) + wiring through BOTH the deflected path and `run_agent` + the per-turn log line + unit/integration tests.
|
||||||
|
2. `02_client_sends_history.md` — `runTurn` sends the `conversation` record (minus the current question, with `thinking`) as `history`; retry/reask and stale-regen ride the same single funnel.
|
||||||
|
3. `03_mock_marker_e2e.md` — the mock's `echo my history` marker + the story E2E proving prior Q/A **and** prior thinking blocks reach the model on grounded, deflected, and first-question paths; regressions + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: mapper (mapping, thinking passthrough, oldest-first trim on turn AND char budgets, empty default), schema validation (bad `who`, oversized list/text), message ordering in BOTH branches (grounded `run_agent` + deflected), the per-turn log line's new field — in `tests/unit/` + `tests/integration/test_chat_api.py`.
|
||||||
|
- Coverage: **>90%** on `app/` (all new server code is unit/integration tested).
|
||||||
|
- E2E: new story suite `tests/e2e/test_llm_history.py`, run in isolation, against the deterministic mock (its new `echo my history` marker makes the wire contents byte-assertable).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `POST /api/chat` with a `history` body: both the deflected turn and the grounded agent turn send the prior turns to the model in chronological order, with `reasoning_content` on prior brain turns that had thinking (proven by the mock-echo E2E).
|
||||||
|
- [ ] A request without `history` is byte-identical in behavior to today (default empty — existing suites' mocks see no extra messages; regression runs green).
|
||||||
|
- [ ] The per-turn log line (PLAN §9) gains `history_msgs=N`; `uv run pytest` green; coverage >90%; ruff + pyright clean.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit (e.g. `feat(rag): pass chat history with prior thinking to the LLM`); phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Task 01 — Server: optional client-provided history, trimmed + mapped (incl. `reasoning_content`)
|
||||||
|
|
||||||
|
**Phase:** `74_llm_chat_history` · **Source:** `TODO.md:4` — "Chat history isn't being passed to the LLM. When the LLM responds and you ask a follow-up question the previous question/answer isn't passed to the model. Since my models support preserve thinking, make sure to pass previous thinking blocks as well."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
`POST /api/chat` accepts an optional `history` (the client's prior turns) and feeds it to the model on BOTH turn branches — the deflected path and the grounded agent — with prior thinking blocks preserved via the endpoint's existing `reasoning_content` wire convention; oversized histories are trimmed oldest-first by configurable caps.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `app/config.py` — two new `Settings` fields (existing docstring style, `BOR_`-overridable):
|
||||||
|
- `history_max_turns: int = 40` (`BOR_HISTORY_MAX_TURNS`) — newest history turns kept.
|
||||||
|
- `history_max_chars: int = 24000` (`BOR_HISTORY_MAX_CHARS`) — total kept history chars (text + thinking).
|
||||||
|
2. `app/schemas.py` — next to `ChatRequest`:
|
||||||
|
- `class HistoryTurn(BaseModel)`: `who: Literal["user", "brain"]`, `text: str = Field(min_length=1, max_length=4000)`, `thinking: str | None = Field(default=None, max_length=32000)`.
|
||||||
|
- `ChatRequest.history: list[HistoryTurn] = Field(default_factory=list, max_length=100)` — schema-level hard ceiling (DoS sanity); the config budgets below do the real trimming.
|
||||||
|
3. `app/rag/prompts.py` — new pure helper (unit-testable, no I/O):
|
||||||
|
```python
|
||||||
|
def history_to_messages(history: Sequence[HistoryTurn], settings: Settings) -> list[dict[str, Any]]
|
||||||
|
```
|
||||||
|
- Maps newest-first until BOTH budgets are hit (turn count ≤ `history_max_turns`; cumulative chars — `len(text) + len(thinking or "")` — ≤ `history_max_chars`), then returns the kept turns in chronological (oldest→newest) order.
|
||||||
|
- A budget-exceeding turn is DROPPED WHOLE — never cut mid-answer.
|
||||||
|
- `who="user"` → `{"role": "user", "content": text}`.
|
||||||
|
- `who="brain"` → `{"role": "assistant", "content": text}` plus `"reasoning_content": thinking` ONLY when `thinking` is non-empty (the preserve-thinking wire convention `app/rag/llm.py` already reads on the response side — `delta.reasoning_content`).
|
||||||
|
4. `app/api/chat.py`:
|
||||||
|
- After `settings = get_settings()` in `stream()`: `hist = history_to_messages(request.history, settings)` (once per turn; the deflected recovery below reuses it via `messages`).
|
||||||
|
- Deflected branch: build `messages = [{"role": "system", "content": plan.system_prompt}, *hist, {"role": "user", "content": request.message}]` (replaces the two-entry list at ~L381). The phase-71 recovery already rebuilds from `messages[1:]` — unchanged.
|
||||||
|
- Grounded branch: pass `history=hist` into the `run_agent(...)` call (~L414).
|
||||||
|
- Per-turn log line (PLAN §9, the `logger.info` at ~L636): add `history_msgs={len(hist)}` to the existing line (append it near the other context-size fields, e.g. after `kb_chars`).
|
||||||
|
5. `app/rag/agent.py` — `run_agent(..., history: Sequence[dict[str, Any]] = (), ...)`: build `messages = [{"role": "system", "content": system_prompt}, *history, {"role": "user", "content": user_message}]` (~L863). Everything downstream (tool rounds, phase-71 recovery rebuilding from `messages`, retry restarts) already operates on `messages` — unchanged.
|
||||||
|
6. `app/rag/llm.py` — widen the message parameter type of `chat`, `chat_stream`, and `chat_stream_retried` from `list[dict[str, str]]` to `list[dict[str, Any]]` (an assistant message may now carry `reasoning_content`; the openai SDK passes message dicts through to the request body verbatim, so no transport change — string-only messages stay byte-identical on the wire).
|
||||||
|
7. Tests:
|
||||||
|
- `tests/unit/` (extend the existing prompts/chat test files, e.g. `tests/unit/test_chat_gate.py` or a new `tests/unit/test_history.py`): `history_to_messages` — empty default → `[]`; user/brain mapping; `reasoning_content` present only when thinking non-empty; turn-cap trim (newest kept, oldest dropped whole); char-cap trim (text+thinking accounted, drop-whole semantics); chronological order of the result.
|
||||||
|
- `tests/unit/test_config.py`: the two new settings + `BOR_` env overrides (existing pattern).
|
||||||
|
- `tests/unit/test_agent.py`: `run_agent` with a non-empty `history` places it between system and the current user message (mock `LLMClient` capturing the sent messages); default `history=()` keeps the two-message request.
|
||||||
|
- `tests/integration/test_chat_api.py`: `POST /api/chat` with `history` — deflected turn (off-topic message) and grounded turn (on-topic message) both forward the prior turns to the mock LLM client (assert on the captured request messages, including `reasoning_content`); a request without `history` sends exactly `[system, user]` (byte-identical behavior pin); schema rejection: `who="alien"` → 422; `history` > 100 entries → 422; the per-turn log line carries `history_msgs=N` (caplog).
|
||||||
|
- - ASSUMPTION A2 (owner-confirmed 2026-09-08): `/api/chat` stays STATELESS per owner-locked A10 — the history is client-provided in the request body; the server stores nothing new (no `saved_chats`/`query_log` change beyond the log field).
|
||||||
|
- - ASSUMPTION A3 (owner-confirmed 2026-09-08): history is sent for BOTH grounded and deflected turns; caps = newest 40 turns / 24 000 chars (new `BOR_HISTORY_MAX_TURNS` / `BOR_HISTORY_MAX_CHARS`); a capped-out turn is dropped whole, never truncated.
|
||||||
|
- - ASSUMPTION A4 (owner-confirmed 2026-09-08): prior thinking travels as `reasoning_content` on the assistant message (the convention `app/rag/llm.py` already documents for the response side), only when non-empty — this is what makes the owner's preserve-thinking models keep the reasoning chain.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration as listed in Work item 7 — every branch of the mapper and both API branches is covered.
|
||||||
|
- Coverage: **>90%** on `app/` for this task's new/modified code (the mapper is pure — easy to pin).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `history_to_messages` unit tests green (mapping, `reasoning_content` gating, both budgets, drop-whole, ordering).
|
||||||
|
- [ ] Integration: deflected AND grounded turns forward history to the mock LLM; no-`history` requests are unchanged; 422 pins; log line gains `history_msgs=N`.
|
||||||
|
- [ ] `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
- [ ] No commit in this task (task 03 commits the phase) — but `uv run .agents/validate.sh`-equivalent gates must pass.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 02 — Client: `runTurn` sends the conversation history (with thinking) in the request body
|
||||||
|
|
||||||
|
**Phase:** `74_llm_chat_history` · **Source:** `TODO.md:4` — "Chat history isn't being passed to the LLM. When the LLM responds and you ask a follow-up question the previous question/answer isn't passed to the model. Since my models support preserve thinking, make sure to pass previous thinking blocks as well."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The browser sends the prior conversation on every turn — user turns, brain answers, and the brain turns' `thinking` blocks — as the new `history` field of the `POST /api/chat` body, so follow-up questions are answered WITH the conversation the user has already seen.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/app.js` — in `runTurn` (~L1859), the single funnel for ALL turns (fresh send, phase-49 retry via `runTurn(text, { reask: true })`, phase-53 stale-regen — verify the invariant while editing: in every one of those paths the question being sent is the LAST entry of `conversation` at fetch time — a fresh send just pushed it; `retryLastTurn` ~L1798 pops the old brain record and keeps the question as the last entry; `regenerateStaleChat` ~L1617 follows the same pop-then-reask pattern):
|
||||||
|
```js
|
||||||
|
const history = conversation.slice(0, -1).map((m) => ({
|
||||||
|
who: m.who,
|
||||||
|
text: m.text,
|
||||||
|
thinking: m.who === "brain" ? m.thinking || undefined : undefined,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
and send `body: JSON.stringify({ message: text, history })` in the existing `fetch("/api/chat", …)` (~L1905, replacing `{ message: text }`).
|
||||||
|
- `thinking` is only present on brain records that actually streamed one (phase-17+ optional key) — `undefined` drops the key from the JSON (the record's existing convention).
|
||||||
|
- Old/restored records without `thinking` send none — the server maps that to a plain assistant message.
|
||||||
|
- The current question itself is NEVER in `history` (the `slice(0, -1)`) — it is the request's `message`, exactly as the server expects.
|
||||||
|
- No UI change: nothing renders differently; the request body just carries the record the user already sees.
|
||||||
|
2. - ASSUMPTION A5 (owner-confirmed 2026-09-08): stopped/partial brain turns (the `stopped: true` records) are included in the history like any other prior answer — the persisted record is the source of truth, and what the user saw is what the model should treat as the prior answer.
|
||||||
|
3. Sanity check (no code): the server-side schema (task 01) accepts exactly `{who, text, thinking}` — no other record keys (`sources`, `tools`, `deflected`, `suggestions`) travel in the body; `tools` metadata is display-only and was never part of the LLM wire.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No JS unit-test infra in this repo — the wire-level pin is task 03's E2E (the mock sees the exact messages the client sent).
|
||||||
|
- Manual smoke while running the dev server: DevTools → Network → the second `/api/chat` request's body carries the first Q/A pair (and `thinking` when the first turn streamed one).
|
||||||
|
- `uv run pytest` stays green (frontend-only change).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] Every `/api/chat` request carries `history` = the `conversation` record minus the current question, with `thinking` on brain turns that have it (verified in DevTools for a 2-turn conversation).
|
||||||
|
- [ ] Retry (phase 49) and stale-regen (phase 53) send the correct history for their re-asked question (the popped/replaced answer is NOT in the history; the question is NOT duplicated).
|
||||||
|
- [ ] No UI change; `uv run pytest` green; ruff + pyright clean.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 03 — Mock `echo my history` marker + story E2E + regressions + commit
|
||||||
|
|
||||||
|
**Phase:** `74_llm_chat_history` · **Source:** `TODO.md:4` — "Chat history isn't being passed to the LLM. When the LLM responds and you ask a follow-up question the previous question/answer isn't passed to the model. Since my models support preserve thinking, make sure to pass previous thinking blocks as well."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove on the wire, deterministically, that a follow-up question reaches the model WITH the prior Q/A and the prior thinking blocks — grounded and deflected branches alike — then run the regressions and commit the phase.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/mock_llm.py` — new marker, following the module's established convention (trigger phrase in the user message → deterministic, byte-stable answer; checked BEFORE the `DEFLECT_MODE` branch exactly like `TABLE_TRIGGER`, so a marker question always gets the echo whatever the honesty gate says):
|
||||||
|
- `HISTORY_TRIGGER = "echo my history"` — first verify no existing E2E question or fixture file contains the phrase (the module's standing convention).
|
||||||
|
- The echo answer (byte-stable), derived statelessly from the request messages:
|
||||||
|
- `history: N prior messages` — N = the count of non-`system` messages before the LAST `user` message (i.e. everything the client sent as prior turns; the current question itself is excluded).
|
||||||
|
- `last answer tail: <…>` — the LAST 24 chars of the content of the most recent `assistant` message before the current user message, or `none` when there is no prior assistant message.
|
||||||
|
- `thinking: yes|no` — `yes` iff that most recent prior `assistant` message carries a non-empty `reasoning_content` field (the client's phase-74 history mapping), `no` otherwise (or when there is no prior assistant message).
|
||||||
|
- Suffix `(Deterministic mock answer for E2E.)` like the other composed answers.
|
||||||
|
- Docstring entry documenting the marker + the invariant it relies on: **client history contains only `user`/`assistant` messages — never `tool`-role ones** (the client never sends tool calls/results) — so every existing marker flow (which classifies statelessly from tool results) is unaffected by the now-always-present history.
|
||||||
|
2. `tests/e2e/test_llm_history.py` (Playwright; conftest app-boot + KB fixture pattern from `tests/e2e/test_chat_rag.py`; the file name deliberately differs from phase 50's `test_chat_history.py`):
|
||||||
|
- `test_followup_receives_history_and_thinking` — turn 1: an on-topic question carrying `think out loud` (deterministic reasoning stream) → `done`; capture the rendered answer text. Turn 2: an on-topic question carrying `echo my history` → `done`; assert the bubble contains `history: 2 prior messages`, `thinking: yes`, and `last answer tail: ` + the last 24 chars of turn 1's answer (byte-exact from the mock).
|
||||||
|
- `test_first_question_has_no_history` — fresh context, one on-topic `echo my history` question → `history: 0 prior messages`, `thinking: no`, `last answer tail: none` (no phantom history on a cold start).
|
||||||
|
- `test_deflected_followup_receives_history` — turn 1 on-topic with `think out loud`; turn 2 an OFF-TOPIC question carrying `echo my history` (mock embeddings → LOW gate → deflected branch) → the echo answer still appears with `history: 2 prior messages` + `thinking: yes` (ASSUMPTION A3: both branches carry history — the marker is checked before the `DEFLECT_MODE` branch, so the deflected path is what this test proves).
|
||||||
|
3. Regression runs (isolation, AGENTS.md rule 9) — the client now ALWAYS sends history, so the suites that touch the chat wire run: `test_chat_rag.py`, `test_chat_history.py` (phase 50), `test_agent_document_tools.py`, `test_harness_aligned_tools.py` (the agent's message list changed shape), `test_stop_generation.py`, `test_retry_answer.py` (retry's history = the re-ask, per task 02), `test_response_to_docs.py` (saveAsDoc untouched in this phase).
|
||||||
|
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
5. Commit (Conventional Commits, `--no-gpg-sign`) — e.g. `feat(rag): pass chat history with prior thinking to the LLM`; move `.agents/phases/todo/74_llm_chat_history/` → `.agents/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_llm_history.py -v --no-cov` green in isolation (DB up, mock LLM).
|
||||||
|
- Coverage: **>90%** on `app/` (task 01's new code is unit/integration-pinned; this task adds no `app/` code).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The three E2E tests pass: prior Q/A AND prior `reasoning_content` reach the model on follow-ups (grounded + deflected); cold start carries no history.
|
||||||
|
- [ ] The mock's docstring carries the marker + the user/assistant-only invariant; no existing suite's trigger phrases or fixtures collide with `echo my history`.
|
||||||
|
- [ ] Regression suites green in isolation; full suite + coverage >90% + ruff + pyright clean.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Phase 75 — "Save as doc" captures the whole chat session
|
||||||
|
|
||||||
|
**Source:** `TODO.md` L4 — "Chat history isn't being passed to the LLM. When the LLM responds and you ask a follow-up question the previous question/answer isn't passed to the model. Since my models support preserve thinking, make sure to pass previous thinking blocks as well. Then, update the \"save as doc\" process to include the output from the entire chat session rather than the last response. The user can edit out anything they don't want to keep from previous replies." (this phase covers the last two sentences; the history sentences are phase 74)
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
**Context:** `frontend/assets/app.js` (`appendSaveAsDocButton(wrap, markdown)` ~L591, `saveAsDoc(btn, markdown)` ~L615 — today the body is ONLY the clicked bubble's raw answer; `defaultDocTitle()` ~L562; the `conversation` array is the full `bor.chat.v1` record), `app/api/doc_drafts.py` (draft create/edit/push — unchanged by this phase), `frontend/doc-edit.html` + `frontend/assets/doc-edit.js` (the existing edit screen: title / path / free-form editable body — the user's means of editing turns out), phase-59 E2E `tests/e2e/test_response_to_docs.py` (bare-repo fixture + admin flow + the single-turn body expectation that must move to the transcript shape), the mock's default composed answer (embeds the question's first 80 chars → different questions give byte-distinct deterministic answers).
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
"Save as doc" drafts a document from the ENTIRE chat session — every question and answer up to the click — instead of only the last response; the existing doc-edit screen's free-form body editing is how the user edits out anything they don't want to keep from previous replies (no new UI surface).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- `74_llm_chat_history` (todo) — the chat turn path (including its E2E pins) is settled first; this phase only touches the save-as-doc side of the same page.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. `01_session_transcript.md` — `buildSessionTranscript()` in `app.js`; `saveAsDoc` posts the transcript as the draft body; the phase-59 single-turn E2E expectation moves to the transcript shape.
|
||||||
|
2. `02_e2e_full_session_save.md` — multi-turn session → save → all turns present in order → edit a turn out in doc-edit → push → bare-repo git verification; regressions + commit.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- Unit/integration: frontend-only change — no `app/` code (the `doc_drafts` API is untouched; the >90% `app/` floor holds by the full suite).
|
||||||
|
- E2E: new story suite `tests/e2e/test_save_doc_session.py` (run in isolation) + the updated expectation in the existing `tests/e2e/test_response_to_docs.py`, both on the bare-repo fixture (git as source of truth).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The draft body for an N-turn session contains every turn in order (`## N. <question>` + the answer's raw markdown); the doc-edit screen shows the full transcript prefilled; editing a section out and pushing lands exactly the edited body in `BOR_DOCS_REPO` (verified with git, not UI text).
|
||||||
|
- [ ] The single-turn behavior of phase 59 is preserved in transcript shape (updated E2E green).
|
||||||
|
- [ ] `uv run pytest` green; coverage >90%; ruff + pyright clean.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit (e.g. `feat(docs): save the whole chat session as a doc`); phase dir moved to `.agents/phases/complete/`.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Task 01 — `saveAsDoc` posts a full-session transcript; single-turn E2E expectation updated
|
||||||
|
|
||||||
|
**Phase:** `75_save_doc_full_session` · **Source:** `TODO.md:4` — "Then, update the \"save as doc\" process to include the output from the entire chat session rather than the last response. The user can edit out anything they don't want to keep from previous replies."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
The "Save as doc" draft's body becomes the whole conversation — every user question and brain answer up to the click, in order — instead of only the clicked bubble's answer; the user then edits out unwanted turns in the existing doc-edit body field before pushing.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `frontend/assets/app.js` — add `buildSessionTranscript()` next to `defaultDocTitle()`/`saveAsDoc()` (~L562–L615):
|
||||||
|
- Walks the `conversation` record in order. Each `who === "user"` entry opens a section; each following brain entry (up to the next user entry) is appended under it:
|
||||||
|
```markdown
|
||||||
|
## 1. <user question, raw text>
|
||||||
|
|
||||||
|
<brain answer, raw text>
|
||||||
|
|
||||||
|
## 2. <user question, raw text>
|
||||||
|
|
||||||
|
<brain answer, raw text>
|
||||||
|
```
|
||||||
|
- Numbering: 1-based per USER turn (a section per question; normally one answer per section).
|
||||||
|
- Trailing whitespace collapsed to a single final newline.
|
||||||
|
- - ASSUMPTION A6 (owner-confirmed 2026-09-08): the transcript format is the numbered `## N. <question>` + raw answer markdown shown above; ALL turns at click time are included (even when the button is on an earlier bubble); NO thinking blocks, NO source chips, NO tune metadata travel into the document; the default TITLE is unchanged (`defaultDocTitle()` — last user question, ≤120 chars).
|
||||||
|
- - ASSUMPTION A7 (owner-confirmed 2026-09-08): "edit out anything they don't want to keep" = free-form editing in the EXISTING doc-edit body field (`frontend/doc-edit.html` / `frontend/assets/doc-edit.js` already expose the body as an editable textarea) — no new per-turn selection UI in this phase.
|
||||||
|
- Stopped/partial brain turns appear as-is (their `text` is what the user saw); the user edits them out if unwanted (A7).
|
||||||
|
2. `frontend/assets/app.js` — `saveAsDoc`: the draft's `body` becomes `buildSessionTranscript()` instead of the bubble's `markdown` (the POST to `/api/doc-drafts` at ~L623 otherwise unchanged: same `title` from `defaultDocTitle()`, same `docs/<slug>.md` path, same 201→`/doc-edit.html?draft=<token>` hand-off, same double-click guard). The `markdown` parameter of `saveAsDoc` is then unused — drop the parameter and update its single call site (`appendSaveAsDocButton`'s click binding ~L607); keep `appendSaveAsDocButton(wrap, markdown)`'s signature (the button is still appended per bubble).
|
||||||
|
3. `tests/e2e/test_response_to_docs.py` — `test_save_edit_push`'s body expectation: today "body == the rendered answer's markdown source" → the SINGLE-turn transcript `## 1. <the question>\n\n<the mock answer>` (byte-stable: the mock's default composed answer embeds the question's first 80 chars). Everything else in that suite (push → git verification, guest/unconfigured pins) is unchanged.
|
||||||
|
4. No backend change: `app/api/doc_drafts.py` accepts any body string; `frontend/doc-edit.html` renders it into the editable body as before.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- No `app/` code — coverage floor unaffected; the wire-level proof is the E2E (this task updates the existing phase-59 suite; task 02 adds the multi-turn suite).
|
||||||
|
- Manual smoke: 2-turn conversation → Save as doc → the edit screen's body shows both Q/A sections before any editing.
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] `buildSessionTranscript()` produces the exact shape above for multi-turn records (section per user turn, raw texts, single trailing newline).
|
||||||
|
- [ ] `saveAsDoc` posts the transcript; the parameter cleanup is done (no dead `markdown` argument); the double-click guard and hand-off are unchanged.
|
||||||
|
- [ ] `tests/e2e/test_response_to_docs.py` (updated) green in isolation; `uv run pytest` green; ruff + pyright clean.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Task 02 — E2E: multi-turn session → save → all turns → edit one out → push (git-verified) + regressions + commit
|
||||||
|
|
||||||
|
**Phase:** `75_save_doc_full_session` · **Source:** `TODO.md:4` — "Then, update the \"save as doc\" process to include the output from the entire chat session rather than the last response. The user can edit out anything they don't want to keep from previous replies."
|
||||||
|
**Story:** n/a (TODO-derived)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Prove the whole loop in the browser against the bare repo (git as source of truth, the phase-59 convention): the saved draft carries the ENTIRE session, and the user can edit an unwanted previous reply out before the document lands in the docs repo.
|
||||||
|
|
||||||
|
## Work
|
||||||
|
1. `tests/e2e/test_save_doc_session.py` (Playwright; reuse the bare-repo fixture pattern from `tests/e2e/test_response_to_docs.py`: `git init --bare` + seeded work clone on `main`, app env `BOR_DOCS_REPO`/`BOR_DOCS_BRANCH=bor-docs`/`BOR_DOCS_BASE_BRANCH=main`/`BOR_DOCS_WORK_DIR`, admin login via `tests/e2e/auth_helpers.py`; KB fixture from `tests/e2e/test_chat_rag.py`):
|
||||||
|
- `test_full_session_save_and_edit_out` —
|
||||||
|
a. Three DISTINCT on-topic questions in one session (the mock's default composed answer embeds each question's first 80 chars, so the three answers are byte-distinct and assertable); wait for `done` after each.
|
||||||
|
b. Click "Save as doc" on the LAST brain bubble → `/doc-edit.html?draft=<uuid>`; assert the prefilled body contains `## 1.`, `## 2.`, `## 3.` IN ORDER, each followed by that turn's answer text (byte-exact against the mock), and that the title is the last question (whitespace-collapsed ≤120 chars — the unchanged `defaultDocTitle()`); assert NO thinking scratchpad text leaked into the body (turn 1 asked with `think out loud`, so the record carries a `thinking` block the transcript must exclude).
|
||||||
|
c. Edit out a previous reply: delete the entire section-2 block (heading + answer) from the body textarea; Push.
|
||||||
|
d. Assert the status live region shows branch + sha, then GIT-VERIFY: `git -C <bare> show bor-docs:<path>` equals the EDITED body exactly — sections 1 and 3 present, section 2 (both question and answer) gone; the UI's sha prefix equals `git rev-parse bor-docs` (7 chars).
|
||||||
|
- `test_earlier_bubble_button_saves_whole_session` — click "Save as doc" on the FIRST brain bubble after the third turn has completed → the draft body still contains all three sections (ASSUMPTION A6: the transcript is the whole session at click time, regardless of which bubble's button was used); cancel out without pushing (no git change: `bor-docs` still has the previous commit).
|
||||||
|
2. Regression runs (isolation, AGENTS.md rule 9): `test_response_to_docs.py` (after task 01's expectation update — the phase-59 contract survives in transcript shape), `test_chat_rag.py` (the meta-row button on every bubble), `test_cache_busting.py` (page list unchanged — no new page in this phase).
|
||||||
|
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
|
||||||
|
4. Commit (Conventional Commits, `--no-gpg-sign`) — e.g. `feat(docs): save the whole chat session as a doc`; move `.agents/phases/todo/75_save_doc_full_session/` → `.agents/phases/complete/`.
|
||||||
|
|
||||||
|
## Testing & Quality
|
||||||
|
- E2E: `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` green in isolation (DB up, `git` on PATH — the phase-59 `pytest.skip` guard on absence).
|
||||||
|
- Coverage: **>90%** on `app/` (no `app/` changes in this phase).
|
||||||
|
|
||||||
|
## Completion Criteria
|
||||||
|
- [ ] The full-session → edit-out → push loop passes with the bare repo's file content as the assertion (section 2 provably absent from the pushed file; sections 1 and 3 byte-exact).
|
||||||
|
- [ ] The earlier-bubble button saves the whole session (A6 pinned); the thinking block never reaches the document.
|
||||||
|
- [ ] Regression suites green in isolation; full suite + coverage >90% + ruff + pyright clean.
|
||||||
|
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agents/phases/complete/`.
|
||||||
Reference in New Issue
Block a user