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/`.
|
||||
Reference in New Issue
Block a user