fix(chat): keep generating while the tab is hidden

Root cause (task 01): none of C1-C3 - in Chromium 151 (real mode) a
merely-hidden tab neither stops the stream (frames arrive at full rate;
turn completes) nor fires pagehide on tab switch; C1's double-record
path was proven latent via a synthetic pagehide (trigger is
browser-dependent, e.g. Safari) and C2 (the 120s pre-token guard) was
confirmed to fire while hidden.

- C1: the pagehide partial-persist is correlated with the turn's settle
  (leavePartialIndex) - the done/stop settle REPLACES it in place
  (identity-guarded rememberBrainTurn in-place mode), so bor.chat.v1
  and the auto-saved saved_chats row keep exactly ONE brain turn per
  question; a real navigation never runs a settle, so the leave-save
  is unchanged.
- C2: the visibility re-arm gives the still-armed pre-token guard a
  fresh TURN_TIMEOUT_MS when the tab returns to visible - hidden time
  no longer counts toward the 120s guard.
- Phase-48 teardown contract untouched: Stop / tab close / real
  navigation still cancel the fetch and stop the model.
- Unit pins: tests/unit/test_frontend_hidden_tab.py (the app.js
  mechanisms without a browser).
- E2E pins: tests/e2e/test_hidden_tab_stream.py - synthetic pagehide
  mid-stream completes exactly once with one brain turn (localStorage
  + auto-saved row), reload restores one bubble, no-event baseline,
  and the fake-clock pre-token guard re-arm (discriminating: fails
  with the re-arm disabled).
This commit is contained in:
2026-09-05 14:34:33 -04:00
parent 45c3fa2863
commit a16130c71d
28 changed files with 4127 additions and 10 deletions
@@ -0,0 +1,10 @@
**Task 01 complete — repro performed, root cause pinned.**
- Real-browser repro (Chromium 151 headful, 2 real tabs in 1 window, 90s re-paced mock stream, scratch DB — no owner data touched; all instrumentation throwaway, no repo changes):
- **Scenario A:** real tab switch mid-stream → 65s hidden: stream kept arriving at full wire rate, turn completed on return, no error, `bor.chat.v1` = 1 brain turn; server: `total_ms=90143`, **no** `turn cancelled` → C3 ruled out. Tab switch fired only `visibilitychange` — **no `pagehide`** in Chromium.
- **Scenario C:** synthetic `pagehide` mid-stream → **2 brain turns** (336-char partial + 5379-char full) for one question; reload renders both → C1 corruption branch proven latent (trigger is browser-dependent, e.g. Safari).
- **Scenario B:** 130s pre-token hold + hidden tab → 120s `TURN_TIMEOUT_MS` guard fired **while hidden** ("That's taking a long time — the answer may be stuck."), server `turn cancelled total_ms=119999` (client-initiated) → C2 confirmed.
- Verdict: **none of C1–C3 fired on a simple tab switch in the test browser** (answer continues + completes; no stall, no cancel, no pagehide); C1 latent path + C2 conditional path pinned with evidence (`.agents/reports/73_hidden_tab_stream/task01_findings.md` + JSONs/screenshots/server log).
- Gates: `uv run ruff check .` clean; `uv run pyright` 0 errors; `uv run pytest --cov=app` → **1511 passed, app/ coverage 99%** (>90%); E2E `test_smoke.py` 3 passed.
- Notable: first attempt's "hidden" state was an X11 focus flake (XWayland); re-run used stable-focus assertions + contamination detection (exactly 1 hidden/1 visible event). No timer throttling observed in this environment — noted, not an app defect.
- Next pending task: `73_hidden_tab_stream/02_fix_hidden_tab.md`.
@@ -0,0 +1,77 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 28%]
........................................................................ [ 33%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 47%]
........................................................................ [ 52%]
........................................................................ [ 57%]
........................................................................ [ 61%]
........................................................................ [ 66%]
........................................................................ [ 71%]
........................................................................ [ 76%]
........................................................................ [ 81%]
........................................................................ [ 85%]
........................................................................ [ 90%]
........................................................................ [ 95%]
....................................................................... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 176 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 126 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 52 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 65 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 205 0 100%
-----------------------------------------------
TOTAL 2946 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
@@ -0,0 +1,16 @@
All green. Task 02 is complete.
**Implemented (frontend-only, `frontend/assets/app.js`):**
- **C1 hardening (unconditional):** module-scope `leavePartialIndex` (reset in `runTurn`); the `pagehide` handler records the pushed partial's index after `rememberBrainTurn`; `rememberBrainTurn` gained an identity-guarded in-place mode (`replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain"` → replace, else append) with `saveConversation()`+`persistConversation()` running once on the written record; the `done` and stop-path settles pass `leavePartialIndex` so the final record *replaces* the partial (plus the zero-frame fallback, passed for uniform correlation though unreachable with a partial). Real-navigation leave-save unchanged (no settle runs on unload).
- **C2 fix (task 01 confirmed C2):** `turnTimeoutCb` stores the guard callback; a `visibilitychange` listener re-arms the guard with a fresh `TURN_TIMEOUT_MS` when the tab returns to visible with the guard still armed — hidden time no longer counts toward the 120s pre-token guard.
- Stop/teardown contract (phase 48) untouched. New unit pins: `tests/unit/test_frontend_hidden_tab.py` (8 tests).
- Fixed one pre-existing-pin break I introduced then resolved by keeping the done-call on one line (`test_chat_persistence.py` format pin).
**Results (all green):**
- `uv run pytest --cov=app --cov-report=term-missing` → 1519 passed, app/ coverage **99%** (>90% floor)
- `uv run ruff check . && uv run pyright` → clean (0 errors)
- Regression E2E in isolation: `test_stop_generation.py` 3✓, `test_chat_persistence.py` 4✓, `test_chat_history.py` 5✓, `test_retry_answer.py` 4✓, `test_chat_rag.py` 3✓, `test_sources_midstream_bug.py` 4✓, `test_loading_feedback.py` 5✓
**Decisions:** all three runTurn settle write-sites pass the correlation index (task named two; the fallback is defensive, provably unreachable with a partial). Commit is left to task 03 per phase plan (root-cause line goes in that commit body).
**Next pending task:** `.agents/phases/todo/73_hidden_tab_stream/03_e2e_hidden_tab_stream.md`
@@ -0,0 +1,78 @@
........................................................................ [ 4%]
........................................................................ [ 9%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 23%]
........................................................................ [ 28%]
........................................................................ [ 33%]
........................................................................ [ 37%]
........................................................................ [ 42%]
........................................................................ [ 47%]
........................................................................ [ 52%]
........................................................................ [ 56%]
........................................................................ [ 61%]
........................................................................ [ 66%]
........................................................................ [ 71%]
........................................................................ [ 75%]
........................................................................ [ 80%]
........................................................................ [ 85%]
........................................................................ [ 90%]
........................................................................ [ 94%]
........................................................................ [ 99%]
....... [100%]
=============================== warnings summary ===============================
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
Name Stmts Miss Cover
-----------------------------------------------
app/__init__.py 1 0 100%
app/api/__init__.py 0 0 100%
app/api/auth.py 22 0 100%
app/api/chat.py 176 0 100%
app/api/chats.py 110 0 100%
app/api/config.py 7 0 100%
app/api/doc_drafts.py 93 0 100%
app/api/docs.py 50 0 100%
app/api/git_sources.py 212 0 100%
app/api/health.py 10 0 100%
app/api/steering.py 42 0 100%
app/api/suggestions.py 8 0 100%
app/api/sync.py 101 0 100%
app/config.py 126 0 100%
app/core/__init__.py 0 0 100%
app/core/auth.py 20 0 100%
app/core/caching.py 108 0 100%
app/core/debugging.py 29 2 93%
app/core/docs_push.py 39 0 100%
app/core/logging.py 13 0 100%
app/db.py 21 0 100%
app/main.py 52 0 100%
app/models.py 86 0 100%
app/rag/__init__.py 0 0 100%
app/rag/agent.py 222 0 100%
app/rag/archive_upload.py 128 0 100%
app/rag/chunker.py 206 4 98%
app/rag/git_sources.py 14 0 100%
app/rag/importer.py 180 3 98%
app/rag/llm.py 216 0 100%
app/rag/overview.py 71 0 100%
app/rag/prompts.py 65 0 100%
app/rag/retriever.py 150 3 98%
app/rag/scaffolding.py 55 0 100%
app/rag/source_removal.py 41 0 100%
app/rag/sources_meta.py 16 0 100%
app/rag/suggestions.py 27 0 100%
app/rag/summarizer.py 24 0 100%
app/schemas.py 205 0 100%
-----------------------------------------------
TOTAL 2946 12 99%
coverage gate: app/ 99% (>90%) OK
All checks passed!
0 errors, 0 warnings, 0 informations
validation OK
Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

@@ -0,0 +1,127 @@
{
"scenario": "B \u2014 C2 probe: 120s guard in a hidden pre-token window (130s silence)",
"wall_seconds": {
"hide": 6.5,
"return": 138.6
},
"probes": [
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 16.7
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 26.7
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 36.7
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 46.7
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 56.7
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 66.8
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 76.8
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 86.8
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 96.8
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 106.8
},
{
"vis": "hidden",
"bubble": 16,
"btn": "Stop",
"status": "Brain of Reese is thinking",
"banner": "",
"dt": 116.8
},
{
"vis": "hidden",
"bubble": 53,
"btn": "Send",
"status": "",
"banner": "That's taking a long time \u2014 the answer may be stuck. Try again \u2014 if this persists, check the LLM is reachable.",
"dt": 126.8
},
{
"vis": "hidden",
"bubble": 53,
"btn": "Send",
"status": "",
"banner": "That's taking a long time \u2014 the answer may be stuck. Try again \u2014 if this persists, check the LLM is reachable.",
"dt": 136.8
}
],
"final_state": {
"vis": "visible",
"bubble": 53,
"btn": "Send",
"status": "",
"banner": "That's taking a long time \u2014 the answer may be stuck. Try again \u2014 if this persists, check the LLM is reachable."
},
"localStorage_messages": [
{
"who": "user",
"len": 53,
"head": "please write a long answer about your favorite topics"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

@@ -0,0 +1,30 @@
{
"scenario": "C \u2014 synthetic pagehide mid-stream (C1 double-record path)",
"mid_bubble_chars": 327,
"brain_turns_after_completion": [
{
"len": 336,
"head": "1. Step 1: configure node-1 with the homelab defaults and verify that step 1 of the long walkthrough",
"tail": "figure node-3 with the homelab defaults and verify that step"
},
{
"len": 5379,
"head": "1. Step 1: configure node-1 with the homelab defaults and verify that step 1 of the long walkthrough",
"tail": "ng walkthrough is complete before moving on.\nLONG-ANSWER-END"
}
],
"restored_messages": [
{
"who": "user",
"len": 53
},
{
"who": "brain",
"len": 327
},
{
"who": "brain",
"len": 5228
}
]
}
@@ -0,0 +1,150 @@
# Phase 73 / Task 01 — Repro + root-cause findings
**Bug (TODO.md L3):** "Clicking on another tab while an answer is generating
stops that answer from being generated. Responses should continue to generate
unless you outright close the tab."
**Environment (the "test browser"):** real (headful) Chromium
`151.0.0.0` (Playwright chromium-1234 build, X11/XWayland on the owner's
KDE Plasma Wayland desktop, display :0), launched with ONLY
`--disable-popup-blocking` added (needed for the scripted second tab) —
**no** `--disable-*-background*` flags, so real hidden-tab behavior is
under test. The app ran against the deterministic mock LLM
(`tests/e2e/mock_llm.py`) behind a re-pacing proxy (0.2 s per 12-char
SSE frame → a ~90 s stream, a guaranteed mid-stream hidden window), on a
scratch Postgres DB (`bor_repro73`) so no owner data was touched.
Method: two REAL tabs in ONE window (second tab opened via
`window.open`, tab switched via CDP `Target.activateTarget` /
`Page.bringToFront` — verified real by the `document.visibilityState`
transitions). In-page instrumentation (temporary, removed — no repo
changes): `pagehide`/`pageshow`/`visibilitychange`/`freeze`/`resume`/
`beforeunload`/window focus/blur events with timestamps; a 400 ms
recorder (visibility, `hasFocus`, live-bubble text length = frame
arrivals, status/banner/button text); CDP `Network.*` lifecycle of
`POST /api/chat`; `performance.getEntriesByType('resource')`;
localStorage `bor.chat.v1`; server (uvicorn) log.
## Scenario A — mid-stream real tab switch, 65 s hidden (main repro)
`task01_A_evidence.json`, screenshots `task01_A_0…5_*.png`,
server log `server_A.log`.
1. Long answer started; at ~7 s (bubble ≈ 460 chars, `streaming`) the
driver switched to the other tab. Tab A went `hidden` — exactly ONE
`visibilitychange {state:"hidden"}` event, at the switch.
2. **While hidden (65 s) the answer kept generating at full wire rate**:
the live bubble grew linearly 456 → 4141 chars (≈ 58 chars/s = the
stream's 12 chars / 0.2 s frame pace) — SSE frames kept arriving the
entire time; no stall, no drop, no error copy, no banner.
(The 400 ms recorder cadence never degraded — no background timer
throttling was observed in this XWayland/KWin environment; noted as an
environment property, not an app defect.)
3. Returned to the tab at ~72 s: `visibilitychange {state:"visible"}`;
the turn settled ~18 s later — button back to "Send", no banner.
4. `bor.chat.v1`: exactly ONE brain turn (the full 5 379-char answer,
ending `LONG-ANSWER-END`) for the one question.
5. **`pagehide` did NOT fire** on the tab switch (nor `freeze`/`resume`/
`beforeunload`/`pageshow`). In real-mode Chromium 151, a merely-hidden
tab fires only `visibilitychange`.
6. Network: `POST /api/chat` → `requestWillBeSent` →
`responseReceived (200)` → `loadingFinished`. **No `loadingFailed`**
(verified on a clean re-probe: the earlier duplicate/failed entries
were a bug in the repro's own CDP event router, not the browser).
7. Server: the turn's settle line shows `total_ms=90143/90145` — the
server streamed the FULL 90 s to the hidden tab; there is **NO
`chat: turn cancelled`** line for this turn (the phase-48 line that
would prove a real consumer departure).
**⇒ In the test browser, a tab switch does NOT stop the answer (C3 and
C4 ruled out; C1's trigger did not occur; the guard was already cleared
by the first frame, so C2 was not on the path).**
## Scenario C — C1 double-record path (synthetic `pagehide` mid-stream)
`task01_C_evidence.json`, screenshots `task01_C_1…3_*.png`.
Chromium does not fire `pagehide` on a tab switch, but browsers that DO
(e.g. Safari, which bfcaches/freeze-then-`pagehide`s hidden tabs) would
run the app's `pagehide` handler (`frontend/assets/app.js` ~L2162)
mid-turn. Dispatching a synthetic `PageTransitionEvent('pagehide',
{persisted:true})` at ~7 s into the stream (bubble ≈ 327 chars) — the
EXACT code path a real `pagehide` executes — then letting the turn
complete:
- `bor.chat.v1` ended with **TWO brain turns for ONE question**:
the 336-char partial (the pagehide persist) AND the full 5 379-char
answer (the `done` persist).
- After a reload, the restore renders BOTH: user → brain (327) → brain
(5 228) — the duplicated / "truncated-looking" conversation the bug
report describes.
**⇒ C1's corruption branch is real and deterministic whenever the
browser fires `pagehide` on a merely-hidden tab; it never double-persists
in Chromium because Chromium never fires that event for a tab switch.
This is the branch task 02 hardens unconditionally.**
## Scenario B — C2 probe: the 120 s guard in a hidden pre-token window
`task01_B_evidence.json`, screenshots `task01_B_1…3_*.png`.
The proxy was re-run holding the FIRST frame for 130 s (pre-token
silence > `TURN_TIMEOUT_MS`). The tab was switched away at ~7 s and
stayed hidden past the guard's deadline:
- Probes every 10 s while hidden: no banner through t≈117 s; at
t≈127 s the button had returned to "Send" and the banner read
**"That's taking a long time — the answer may be stuck."** — the guard
fired at its 120 s deadline **during the hidden window** and errored
the turn.
- The first frame would have arrived at t≈130 s — too late: the guard
had already aborted the fetch. On return the error banner is still up;
the user perceives "switching tabs killed the answer", but the trigger
is the pre-token silence, not the switch.
- `bor.chat.v1` holds only the user message (pre-token abort persists
nothing brain-side — phase-20 convention).
- Server: `chat: turn cancelled … total_ms=119999` — the cancellation is
the CLIENT's guard abort (client-initiated), not a browser/proxy drop
of the hidden tab.
**⇒ C2 is confirmed as a real, conditional stop path: any turn whose
first frame takes > 120 s errors out if the tab is hidden at the 120 s
mark (the guard is a plain `setTimeout` with no visibility awareness;
where a browser DOES throttle background timers, its fire time drifts
relative to token arrival, as the C2 branch describes).**
## Decision-tree verdict
- **C1 — `pagehide` on tab switch:** did NOT fire in the test browser
(Chromium 151 real mode fires only `visibilitychange` for a hidden
tab). Its double-record corruption path was nonetheless demonstrated
deterministically (synthetic `pagehide`) — latent in browsers that do
fire `pagehide` on hide (e.g. Safari); the trigger is
browser-dependent, so task 02's fix applies unconditionally.
- **C2 — 120 s guard while hidden:** CONFIRMED to fire (scenario B) in
the pre-token-only window, erroring the turn with the "stuck" copy.
- **C3 — real connection cancel:** ruled out (no `turn cancelled` for
the hidden-window turn; `loadingFinished`, no `loadingFailed`; full
90 s server-side stream completed).
- **C4 — none of the above:** in the test browser a merely-hidden tab
did NOT stop the answer — it kept generating at full rate and
completed on return. The owner's symptom did not reproduce on
Chromium 151 desktop; the reproducible stop paths are C2 (slow
pre-token + hidden) and C1's corruption (browsers that fire
`pagehide` on hide, e.g. Safari — where a hidden tab is also the
likelier place for the stream to actually pause).
**One-line root cause (for the phase-73 commit message body):**
`none of C1–C3 — in Chromium 151 (real mode) a merely-hidden tab neither
stops the stream (frames arrive at full rate; turn completes) nor fires
pagehide on tab switch; C1's double-record path was proven latent via a
synthetic pagehide (trigger is browser-dependent, e.g. Safari) and C2
(the 120s pre-token guard) was confirmed to fire while hidden.`
## Cleanup
- No permanent code changes: `git status` shows only this (untracked)
evidence directory; all instrumentation lived in throwaway scripts
under `/tmp/repro73/` and in the browser profiles.
- Scratch DB `bor_repro73` dropped; repro stack (mock 8901 / proxy 8900
/ app 8123) terminated.
+95 -10
View File
@@ -242,6 +242,25 @@
* a successful auto-save re-stamp (both make the row/conversation no
* longer the one the banner describes).
*
* A hidden tab never stops a turn (phase 73, TODO.md L3): a merely-HIDDEN
* tab (switched away from) keeps the stream filling the live bubble and
* the turn completes when the user returns — only tab close, real
* navigation, or the Stop button aborts (the phase-48 teardown contract,
* untouched). Two hardenings: (1) the pagehide partial-persist (phase 20)
* is CORRELATED with the turn's settle — `leavePartialIndex` records the
* index of the brain record the pagehide handler pushed for this turn,
* and the done/stop settle REPLACES that entry in place (identity-guarded
* on the record still being a brain record) instead of appending a second
* brain turn, so the saved record holds exactly one brain turn per
* question even on browsers that fire pagehide on a merely-hidden tab
* (e.g. Safari; Chromium fires only visibilitychange — task 01); a REAL
* navigation never runs a settle, so the leave-save is unchanged there.
* (2) The 120s pre-token guard counts only VISIBLE time: when the tab
* returns to visible with the guard still armed (it clears on the first
* thinking/delta/retry frame), it re-arms with a fresh TURN_TIMEOUT_MS —
* a slow first frame arriving while the tab was hidden past the deadline
* no longer errors the turn (task 01, C2 confirmed).
*
* All DOM ids match frontend/index.html.
*/
@@ -943,6 +962,7 @@ let uiState = UI_STATE.idle;
let thinkingClock = 0; // setInterval id — elapsed-seconds hint
let thinkingStart = 0; // Date.now() when "thinking" began
let turnTimeout = 0; // setTimeout id — 120s pre-token guard
let turnTimeoutCb = null; // the guard's callback — lets visibilitychange re-arm it (phase 73)
/* Turn accumulators + the navigate-away flag (phase 20, owner choice
* 2026-08-24 A1): module scope because the `pagehide` handler reads them
* while a turn is still in flight; reset per turn at the top of
@@ -950,6 +970,18 @@ let turnTimeout = 0; // setTimeout id — 120s pre-token guard
let acc = ""; // accumulated answer text this turn
let thinkingAcc = ""; // accumulated thinking text (persisted with the turn)
let persistedOnLeave = false; // pagehide partial-persist at most once
/* Phase 73 (task 02, TODO.md L3): the pagehide partial is CORRELATED with
* the turn's settle. `leavePartialIndex` is the index in `conversation`
* of the brain record the `pagehide` handler pushed for THIS turn (else
* -1): when the turn later settles (done | stop), its final record
* REPLACES that entry in place (rememberBrainTurn's in-place mode) instead
* of appending a second brain record — one brain turn per question, even
* on browsers that fire pagehide on a merely-hidden tab (C1, task 01).
* Turn-local: reset at the top of runTurn, written only by the pagehide
* handler. The replace is identity-guarded (the index must still point at
* a brain record) — a New-Chat click or restore between pagehide and
* settle falls back to the append. */
let leavePartialIndex = -1; // index of this turn's pagehide partial (-1 = none)
/* Phase 48 (2026-08-29, TODO.md L3): the user-stop machinery.
* `turnAbort` owns the in-flight fetch (the Stop button aborts it; the
* 120s guard aborts the same controller as its backstop); `stoppedByUser`
@@ -981,6 +1013,7 @@ function startThinkingClock() {
function armTurnTimeout(onTimeout) {
clearTurnTimeout();
turnTimeoutCb = onTimeout;
turnTimeout = setTimeout(onTimeout, TURN_TIMEOUT_MS);
}
@@ -989,8 +1022,27 @@ function clearTurnTimeout() {
clearTimeout(turnTimeout);
turnTimeout = 0;
}
turnTimeoutCb = null;
}
/* Phase 73 (task 02; task 01 C2 — confirmed): a merely-HIDDEN tab must
* never stop a turn, but the 120s pre-token guard is a plain setTimeout,
* so hidden time counted toward it: a turn whose first frame lands while
* the tab is hidden past the 120s mark errored with the "stuck" copy
* (the user perceives "switching tabs killed the answer"). When the tab
* returns to VISIBLE with the guard still armed (it is only armed in the
* pre-token window — cleared on the first thinking/delta/retry frame and
* on every terminal transition), re-arm it with a FRESH TURN_TIMEOUT_MS:
* only visible pre-token time counts. While hidden the timer simply runs
* (and the stream itself keeps arriving at full rate — task 01 scenario
* A); real departures (close / navigation / Stop) still abort the fetch
* (phase 48, untouched). */
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible" && turnTimeout && turnTimeoutCb) {
armTurnTimeout(turnTimeoutCb);
}
});
/* The single entry point for chat feedback. Every in-flight state has a
* visible indicator; every terminal state returns the button to "Send".
* Phase 48 (owner-locked 2026-08-29): the button is ONE control with two
@@ -1669,13 +1721,28 @@ async function regenerateStaleChat() {
Phase 17: meta.thinking and phase 37: meta.tools are optional —
`undefined` drops the key from the JSON, so turns without them persist
exactly as before. An empty answer keeps the fallback/"…" text that
was actually rendered — what the user saw is what is stored. */
function rememberBrainTurn(rawText, meta) {
conversation.push({ who: "brain", text: rawText || "…", ...meta });
was actually rendered — what the user saw is what is stored.
Phase 73 (task 02): optional in-place mode — `replaceIndex` >= 0
REPLACES the record at that index instead of appending, so a settle
that follows a pagehide partial (C1) keeps exactly one brain turn per
question. Identity-guarded: 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) — otherwise it falls back to the append.
Either way the record is written once and the save points below run
once (the auto-save refreshes the row exactly once). */
function rememberBrainTurn(rawText, meta, replaceIndex = -1) {
const rec = { who: "brain", text: rawText || "…", ...meta };
if (replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain") {
conversation[replaceIndex] = rec;
} else {
conversation.push(rec);
}
saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the new brain turn + metadata. The pagehide partial
// reuses this helper, so it rides the same path (no extra wiring).
// updates with the (possibly replaced) brain turn + metadata. The
// pagehide partial reuses this helper, so it rides the same path (no
// extra wiring).
persistConversation();
}
@@ -1874,10 +1941,13 @@ async function runTurn(text, { reask = false } = {}) {
let aborted = false; // the 120s guard already took the turn to error
// Phase 20: acc / thinkingAcc / persistedOnLeave live at module scope
// (the pagehide handler reads them) but reset here, so they stay
// turn-scoped exactly like the other turn locals.
// turn-scoped exactly like the other turn locals. Phase 73:
// leavePartialIndex joins them — no pagehide partial exists for this
// turn yet (the pagehide handler records it if one lands).
acc = "";
thinkingAcc = "";
persistedOnLeave = false;
leavePartialIndex = -1;
// Phase 48: a fresh abort owner per turn (cleared in the finally); the
// stop flag resets with the rest of the turn locals.
turnAbort = new AbortController();
@@ -2040,13 +2110,16 @@ async function runTurn(text, { reask = false } = {}) {
// complete (raw text + the done metadata; phase 17: + optional
// thinking, phase 37: + optional tools — `undefined` drops the
// key from the JSON).
// Phase 73 (task 02): if a pagehide partial landed mid-turn, the
// final record REPLACES it in place (leavePartialIndex = -1 when
// there was none — the append, exactly as before).
rememberBrainTurn(finalText || acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
deflected: !!ev.deflected,
sources: ev.sources,
suggestions: ev.suggestions,
});
}, leavePartialIndex);
lastBrainWrap = wrap; // this bubble is now the last brain answer
markLastRetryable(); // phase 49: the Retry button is last-bubble-only
} else if (ev.type === "error") {
@@ -2068,7 +2141,10 @@ async function runTurn(text, { reask = false } = {}) {
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
rememberBrainTurn(fallback, {}); // persist what the user actually saw
// Phase 73: correlated like the other settles — unreachable when a
// pagehide partial exists (that needs acc, which means a wrap), but
// passed so EVERY settle write goes through the same correlation.
rememberBrainTurn(fallback, {}, leavePartialIndex); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
}
@@ -2088,11 +2164,14 @@ async function runTurn(text, { reask = false } = {}) {
persistedOnLeave = true;
appendTuneButton(wrap); // admin-only; parity with the restore path
appendStoppedNote(wrap);
// Phase 73 (task 02): the stopped partial REPLACES a pagehide
// partial (leavePartialIndex) in place — one brain turn for the
// question, marked stopped, never two records.
rememberBrainTurn(acc, {
thinking: thinkingAcc || undefined,
tools: toolAcc.length ? toolAcc : undefined,
stopped: true,
});
}, leavePartialIndex);
lastBrainWrap = wrap; // the stopped partial is the prime retry candidate
markLastRetryable(); // phase 49: Retry on the stopped partial
}
@@ -2158,7 +2237,12 @@ staleRegenBtn?.addEventListener("click", regenerateStaleChat);
* Thinking-only (no answer tokens yet) persists nothing brain-side:
* the question is already saved on send and the user can re-ask.
* `persistedOnLeave` makes this idempotent across pagehide/bfcache
* churn. */
* churn.
* Phase 73 (task 02): the partial is CORRELATED with the turn's settle —
* after the push, `leavePartialIndex` records the record's index, so the
* done/stop settle REPLACES it in place (one brain turn per question,
* C1). A REAL navigation (the page actually unloads) never runs a settle,
* so the partial stays persisted exactly as before. */
window.addEventListener("pagehide", () => {
if (persistedOnLeave) return;
if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)
@@ -2166,6 +2250,7 @@ window.addEventListener("pagehide", () => {
if (!acc) return; // nothing brain-side to save yet
persistedOnLeave = true;
rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });
leavePartialIndex = conversation.length - 1; // the record it just pushed
});
/* Boot: auth state FIRST — it decides whether the restored conversation
+500
View File
@@ -0,0 +1,500 @@
"""Phase 73 E2E (Playwright): a 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."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_hidden_tab_stream.py -v --no-cov
Real tab-switching is browser-environment-specific, so the suite
dispatches the synthetic hidden-``visibilityState`` + ``pagehide`` /
``pageshow`` sequence from the task file — the exact events the
browsers that fire ``pagehide`` on a merely-hidden tab deliver (e.g.
Safari; task 01: Chromium 151 real mode fires only
``visibilitychange``, so the C1 branch is latent there). Against the
deterministic mock LLM (the ~8 s long stream gives a guaranteed
mid-stream window) the pins are:
1. ``test_hidden_tab_does_not_stop_the_answer`` — a synthetic
``pagehide`` mid-turn never stops the answer: the bubble completes
with the FULL mock answer (``LONG-ANSWER-END`` included), no error
banner, the ``bor.chat.v1`` record holds EXACTLY ONE brain turn —
the pagehide partial is REPLACED in place by the ``done`` settle
(task 01's C1 double-record corruption stays pinned dead), the
auto-saved ``saved_chats`` row (admin context, the
``persistConversation`` path) carries the same single brain turn,
and the server settled the turn (one ``query_log`` row — no
phase-48 ``turn cancelled`` teardown for a merely-hidden tab).
2. ``test_reload_after_hidden_tab_restores_one_bubble`` — the same
mid-stream pagehide, then a reload: the restore renders exactly
one brain bubble for the question (a C1 double-record would render
two).
3. ``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).
4. ``test_pre_token_guard_survives_hidden_window`` — task 01's C2
(confirmed): hidden time must not count toward the 120 s pre-token
guard. Playwright's fake clock makes the guard's deadline
deterministic: the clock is fast-forwarded past the guard's
ORIGINAL 120 s deadline while the tab is (synthetically) hidden,
with a return to visible in between — the phase-73 re-arm gives the
still-armed guard a FRESH ``TURN_TIMEOUT_MS``, so the turn still
settles with the answer. Without the re-arm the fast-forwarded
original timer fires with the "stuck" error — the test
discriminates. The task's suggested trigger
(``think out loud then hesitate``) cannot exercise the re-arm: it
streams its scratchpad FIRST, and the first thinking frame clears
the guard — its 4 s pause sits in a post-clear window.
``pretend to think slowly`` (the phase-06 phrasing,
``test_stop_generation.py``'s SLOW_QUESTION) is the mock's only
PURE pre-token silence (3 s, zero frames) — the guard stays armed
the whole window.
The phase-48 teardown contract is covered by the regression runs
(``test_stop_generation.py``, ``test_chat_persistence.py``) — real
Stop / close / navigation still cancel the fetch.
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Locator, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.mock_llm import LONG_ANSWER_LINES, long_answer
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: The phase-11 on-topic long-answer phrasing (house pattern,
#: ``test_stop_generation.py``): the honesty gate is HIGH and the
#: ~900-word answer streams for ~8 s (12 chars / 0.02 s) — the
#: guaranteed mid-stream window.
LONG_QUESTION = "How is my Kubernetes cluster set up? write a long answer"
#: The mock's byte-stable long answer — the EXACT string the stream
#: delivers, so "the full answer" is an exact comparison, not a
#: contains check.
LONG_ANSWER = long_answer()
LONG_ANSWER_END = "LONG-ANSWER-END"
#: The mock's only pure pre-token silence (phase-06 phrasing): a 3 s
#: delay before the FIRST frame, so the 120 s pre-token guard stays
#: armed the whole window (no thinking/delta/retry frame to clear it).
SLOW_QUESTION = "pretend to think slowly then tell me about kubernetes"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
STORAGE_KEY = "bor.chat.v1"
# The typing indicator is itself a .msg.brain — exclude its bubble.
ANSWER = ".msg.brain .bubble:not(.typing)"
#: The synthetic tab-switch sequence (task file): the exact events a
#: browser that fires ``pagehide`` on a merely-hidden tab delivers —
#: the visibility transition (the app's only visibility listener is
#: the phase-73 guard re-arm) + the PageTransitionEvents, with
#: ``persisted: false`` (a tab switch is not a bfcache navigation).
HIDE_JS = """
() => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" });
document.dispatchEvent(new Event("visibilitychange"));
window.dispatchEvent(new PageTransitionEvent("pagehide", { persisted: false }));
}
"""
SHOW_JS = """
() => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
document.dispatchEvent(new Event("visibilitychange"));
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false }));
}
"""
# ---------------------------------------------------------------------------
# KB seeding (house pattern: TRUNCATE-then-import)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int) -> ImportSummary:
"""House reset + the prompt-shaping tables: steering notes and the
KB overview would otherwise append deterministic suffixes to every
mock answer and break the exact-text assertions. ``saved_chats``
is NOT touched (rows persist across suites; test 1 cleans up its
own row)."""
with SessionLocal() as db:
db.execute(
text("TRUNCATE chunks, documents, query_log, steering_notes, kb_overview")
)
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _query_log_count() -> int:
with SessionLocal() as db:
return db.execute(text("SELECT count(*) FROM query_log")).scalar_one()
# ---------------------------------------------------------------------------
# Shared flows
# ---------------------------------------------------------------------------
def _stored_parsed(page: Page) -> dict[str, Any]:
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
assert raw is not None, "the conversation key must exist in localStorage"
return json.loads(raw)
def _assert_no_error_banner(page: Page) -> None:
"""Neither error copy may be up: the "stuck" guard copy (C2) or the
stream-drop copy — a merely-hidden tab settles to idle like a
normal turn."""
banner = page.locator("#kb-banner")
expect(banner).to_be_hidden()
expect(banner).not_to_have_attribute("role", "alert")
expect(banner).not_to_have_class(re.compile(r"is-error"))
def _ask_long(page: Page) -> None:
page.fill("#message-input", LONG_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(LONG_QUESTION)
def _wait_streaming(page: Page) -> Locator:
"""Wait until answer text is visibly streaming (a few delta frames
rendered — the task's mid-stream moment, well inside the ~8 s
stream)."""
answer = page.locator(ANSWER)
answer.wait_for(state="visible", timeout=30_000)
partial = ""
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
partial = answer.inner_text()
if len(partial.split()) >= 8:
break
time.sleep(0.05)
assert len(partial.split()) >= 8, "no answer deltas before the tab switch"
# In flight at the switch: the button IS the enabled Stop control.
expect(page.locator("#send-label")).to_have_text("Stop")
expect(page.locator("#send-btn")).to_have_class(re.compile(r"is-stop"))
return answer
def _wait_done(page: Page, answer: Locator) -> str:
"""Wait for the ``done`` settle: the Send button is back and the
bubble carries the unique final line — no error banner on the way."""
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
expect(page.locator("#send-btn")).not_to_have_class(re.compile(r"is-stop"))
expect(answer).to_contain_text(LONG_ANSWER_END, timeout=30_000)
_assert_no_error_banner(page)
return answer.inner_text()
def _assert_full_answer(text: str) -> None:
"""The bubble carries the FULL mock answer — every one of the 40
numbered steps plus the unique final line (a truncated stream
would be missing its tail)."""
for i in range(1, LONG_ANSWER_LINES + 1):
assert f"Step {i}: configure node-{i}" in text, f"step {i} missing from the answer"
assert LONG_ANSWER_END in text
def _assert_one_brain_turn(page: Page, full: bool) -> dict[str, Any]:
"""The C1 corruption pin: EXACTLY ONE brain turn for the question.
With ``full`` the record's text must be the complete mock answer
byte-for-byte (the ``done`` settle's record); otherwise it is the
pagehide partial (a true prefix of the full answer — its final
line never arrived).
"""
stored = _stored_parsed(page)
assert stored["v"] == 1
whos = [m["who"] for m in stored["messages"]]
assert whos == ["user", "brain"], (
"exactly ONE brain turn for the question "
f"(the pagehide partial must be replaced, never duplicated): {whos}"
)
assert stored["messages"][0]["text"] == LONG_QUESTION
brain = stored["messages"][1]
if full:
assert brain["text"] == LONG_ANSWER, "the record's text is the FULL answer"
assert brain.get("deflected") is False, "the done metadata rides the record"
else:
# The pagehide partial carries no done metadata (the turn never
# settled when it was written) — only who + text.
assert "deflected" not in brain
return brain
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login —
used to call the admin API with plain httpx (the test's API side
sees exactly what the signed-in browser sees)."""
return {c["name"]: c["value"] for c in page.context.cookies() if "name" in c and "value" in c}
def _delete_rows_by_title(app_url: str, cookies: dict[str, str], title: str) -> None:
"""Best-effort cleanup of the auto-saved row (a 404 is fine)."""
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
if r.status_code != 200:
return
for c in r.json()["chats"]:
if c["title"] == title:
httpx.delete(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies)
def _wait_row_full(app_url: str, cookies: dict[str, str], title: str) -> dict[str, Any]:
"""Poll the auto-saved row until it carries the full answer as a
single brain turn (the ``done`` settle's fire-and-forget
``persistConversation`` PUT is the last writer)."""
deadline = time.monotonic() + 15
last: list[dict[str, Any]] = []
while time.monotonic() < deadline:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
rows = (
[c for c in r.json()["chats"] if c["title"] == title]
if r.status_code == 200
else []
)
for c in rows:
row = httpx.get(f"{app_url}/api/chats/{c['id']}", timeout=10, cookies=cookies).json()
brains = [m for m in row["messages"] if m["who"] == "brain"]
if len(brains) == 1 and brains[0]["text"] == LONG_ANSWER:
return row
last = [row]
time.sleep(0.2)
raise AssertionError(
"the auto-saved row never held the full answer as exactly one brain turn; last: "
f"{last!r}"
)
# ---------------------------------------------------------------------------
# 1. Hidden tab mid-stream: the answer completes exactly once, the
# record holds one brain turn
# ---------------------------------------------------------------------------
def test_hidden_tab_does_not_stop_the_answer(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
summary = _reset_db(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page.set_default_timeout(30_000)
# Admin login: the auto-save row (the persistConversation path) is
# reachable, so the saved-chat side gets pinned too.
login(page, app_url, next="/")
cookies = _admin_cookies(page)
_delete_rows_by_title(app_url, cookies, LONG_QUESTION) # stale rows from crashed runs
_ask_long(page)
answer = _wait_streaming(page)
# Mid-stream: the exact tab-switch event sequence (task file).
page.evaluate(HIDE_JS)
# The pagehide partial landed: EXACTLY ONE brain turn — the partial
# (a true prefix of the full answer; its final line never arrived).
# This is the record the C1 bug would have LEFT ALONGSIDE the full
# answer (two brain turns for one question).
partial = _assert_one_brain_turn(page, full=False)
assert partial["text"], "the partial must carry the streamed text"
assert partial["text"] in LONG_ANSWER, "the partial is a prefix of the full answer"
assert LONG_ANSWER_END not in partial["text"], "the final line had not arrived yet"
# Return to visible — the stream keeps filling the live bubble and
# the turn settles.
page.evaluate(SHOW_JS)
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
# The ``done`` settle REPLACED the partial in place: one brain
# turn, and its text is the full answer.
_assert_one_brain_turn(page, full=True)
# Server side: the turn SETTLED — one query_log row, so no
# phase-48 "turn cancelled" teardown fired for a merely-hidden tab.
assert _query_log_count() == 1
# Auto-save (admin): the row carries the same single full brain
# turn (the shared record shape).
try:
row = _wait_row_full(app_url, cookies, LONG_QUESTION)
brains = [m for m in row["messages"] if m["who"] == "brain"]
assert len(brains) == 1, "the saved row holds exactly one brain turn"
assert brains[0]["text"] == LONG_ANSWER
finally:
_delete_rows_by_title(app_url, cookies, LONG_QUESTION)
# ---------------------------------------------------------------------------
# 2. Reload after the hidden tab: exactly one brain bubble restores
# ---------------------------------------------------------------------------
def test_reload_after_hidden_tab_restores_one_bubble(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
_ask_long(page)
answer = _wait_streaming(page)
page.evaluate(HIDE_JS)
page.evaluate(SHOW_JS)
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
_assert_one_brain_turn(page, full=True)
# The restore path re-renders the bor.chat.v1 record: a C1
# double-record (partial + full) would render TWO brain bubbles.
page.reload()
expect(page.locator("#empty-state")).to_be_hidden()
expect(page.locator(".msg.user .bubble")).to_have_count(1)
expect(page.locator(".msg.user .bubble").first).to_contain_text(LONG_QUESTION)
expect(page.locator(ANSWER)).to_have_count(1)
restored = page.locator(ANSWER).first
_assert_full_answer(restored.inner_text())
_assert_no_error_banner(page)
expect(page.locator("#send-label")).to_have_text("Send")
# The record the restore read: still exactly one brain turn, full
# text (the restore is read-only).
_assert_one_brain_turn(page, full=True)
# ---------------------------------------------------------------------------
# 3. Baseline: the same long question, NO dispatched events
# ---------------------------------------------------------------------------
def test_baseline_no_pagehide_still_completes(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The normal path, untouched by the fix: the long answer completes
identically without any synthetic tab-switch events (guards against
an over-eager fix changing the ordinary settle)."""
_reset_db(mock_llm)
page.set_default_timeout(30_000)
page.goto(app_url)
_ask_long(page)
answer = _wait_streaming(page)
done_text = _wait_done(page, answer)
_assert_full_answer(done_text)
_assert_one_brain_turn(page, full=True)
assert _query_log_count() == 1
# ---------------------------------------------------------------------------
# 4. C2 (task 01, confirmed): the pre-token guard survives a hidden
# window — hidden time does not count toward TURN_TIMEOUT_MS
# ---------------------------------------------------------------------------
def test_pre_token_guard_survives_hidden_window(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""The 120 s pre-token guard is a plain ``setTimeout`` — without the
phase-73 visibility re-arm, hidden time counts toward it and a turn
whose first frame lands past the 120 s mark errors with the "stuck"
copy (task 01 scenario B, C2 confirmed). The fake clock makes the
deadline deterministic (waiting 120 s of real time is not an
option for a regression suite): the mock's 3 s real-time pre-token
silence keeps the guard armed while the FAKE clock is fast-forwarded
across the original deadline — with a return to visible in between,
which re-arms the still-armed guard with a fresh TURN_TIMEOUT_MS.
Timeline (fake clock, the guard armed at t=0, deadline t=120 s):
* t=0 — the slow question is sent; the guard arms (t=120 s);
* t~0 — the tab goes hidden (synthetic; pre-token, so the
pagehide handler persists nothing — the question is
already saved);
* t=115 — fast-forward while hidden: the original timer (120 s)
is not due yet;
* t=115 — back to visible: the re-arm fires (the guard is still
armed — no frame has arrived) → new deadline t=235 s;
* t=229 — fast-forward again: PAST the original 120 s deadline —
without the re-arm the guard fires HERE with the "stuck"
error — but short of the re-armed 235 s deadline.
Meanwhile (real time, ~3.5 s after the send) the mock's first frame
lands, clears the guard for good, and the turn settles with the
full answer.
"""
_reset_db(mock_llm)
page.set_default_timeout(30_000)
# The fake clock BEFORE the first navigation: every app timer (the
# 120 s guard among them) becomes test-controlled, while the mock's
# real-time stream is unaffected (the network is not a timer).
page.clock.install()
page.goto(app_url)
page.fill("#message-input", SLOW_QUESTION)
page.click("#send-btn")
expect(page.locator("#send-label")).to_have_text("Stop", timeout=30_000)
# The pre-token window: no answer frame yet (the mock's 3 s real
# silence is running) — the armed guard is the only thing that can
# now stop the turn.
expect(page.locator(ANSWER)).to_have_count(0)
page.evaluate(HIDE_JS)
page.clock.fast_forward(115_000)
page.evaluate(SHOW_JS) # the re-arm: a fresh TURN_TIMEOUT_MS
page.clock.fast_forward(114_000) # past 120 s, short of the re-armed 235 s
# The turn still settles with the full answer — no "taking a long
# time" error, no aborted stream.
answer = page.locator(ANSWER)
expect(answer).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
_assert_no_error_banner(page)
# One brain turn for the question — the settled answer (pre-token
# hidden time persisted nothing, as the phase-20 convention says).
stored = _stored_parsed(page)
assert [m["who"] for m in stored["messages"]] == ["user", "brain"]
assert stored["messages"][0]["text"] == SLOW_QUESTION
assert MOCK_ANSWER_MARKER in stored["messages"][1]["text"]
+217
View File
@@ -0,0 +1,217 @@
"""Unit: a hidden tab never stops a turn (phase 73, task 02).
The JS behavior itself is E2E-covered (tests/e2e/test_hidden_tab_stream.py,
task 03); here we pin the app.js mechanisms the story depends on — the
pagehide-partial ↔ settle correlation (C1 hardening, unconditional) and the
visibility-aware pre-token guard (task 01, C2 confirmed) — so a silent
regression in app.js is caught without a browser (house pattern:
tests/unit/test_frontend_feedback.py).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
# ---------- C1: the pagehide partial is replaced, never duplicated ----------
def test_leave_partial_index_is_a_module_turn_local() -> None:
"""`leavePartialIndex` is a module-scope turn local (like
persistedOnLeave): declared -1, reset at the top of runTurn, and
written ONLY by the pagehide handler (the one `= conversation.length
- 1` assignment). No other code may move the correlation."""
js = _js()
assert "let leavePartialIndex = -1" in js, "module-scope declaration"
# The two -1 writes are the declaration and the runTurn reset.
assert js.count("leavePartialIndex = -1;") == 2, (
"only the declaration and the runTurn reset write -1"
)
# The one and only index write is the pagehide handler's.
assert js.count("leavePartialIndex = conversation.length - 1") == 1, (
"only the pagehide handler records the partial's index"
)
# The reset sits at the top of runTurn, with the other turn locals
# (the acc / thinkingAcc / persistedOnLeave group, just before the
# fresh AbortController).
turn = js.find("async function runTurn")
abort_idx = js.find("turnAbort = new AbortController()", turn)
assert -1 < turn < abort_idx, "runTurn must exist"
turn_top = js[turn:abort_idx]
assert "leavePartialIndex = -1;" in turn_top, "reset per turn at the top"
assert turn_top.index("persistedOnLeave = false;") < turn_top.index(
"leavePartialIndex = -1;"
), "reset alongside the other turn locals (phase-20 group)"
def test_pagehide_records_the_partial_index_after_the_push() -> None:
"""The pagehide handler keeps its phase-20 guards (idempotency,
in-flight only, nothing brain-side yet) and, AFTER the partial is
pushed through rememberBrainTurn, records the pushed record's index
so the turn's settle can find it."""
js = _js()
fn = js.find('window.addEventListener("pagehide"')
assert fn != -1, "the pagehide listener must exist"
body = js[fn: js.find("\n});", fn)]
assert "if (persistedOnLeave) return;" in body, "phase-20 idempotency guard"
assert "if (!acc) return;" in body, "nothing brain-side yet → no save"
i_guard = body.index("persistedOnLeave = true;")
i_push = body.index("rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });")
i_index = body.index("leavePartialIndex = conversation.length - 1")
assert i_guard < i_push < i_index, (
"flag → push the partial → record its index (the record it just pushed)"
)
def test_remember_brain_turn_replaces_in_place_with_identity_guard() -> None:
"""rememberBrainTurn's optional in-place mode: `replaceIndex >= 0`
REPLACES the record at that index instead of appending — and only
when the index STILL points at a brain record (the identity guard:
a New-Chat click or restore between pagehide and settle falls back
to the append). Either way the record is written once, and the
save points (localStorage + the headless auto-save) run once on the
written record."""
js = _js()
fn = js.find("function rememberBrainTurn")
assert fn != -1, "rememberBrainTurn must exist"
body = js[fn: js.find("\n}\n", fn)]
assert "function rememberBrainTurn(rawText, meta, replaceIndex = -1)" in body, (
"the optional in-place mode defaults to the plain append"
)
assert 'const rec = { who: "brain", text: rawText || "…", ...meta }' in body, (
"one record object, written once"
)
assert (
'if (replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain")' in body
), "identity guard: the index must still point at a brain record"
assert "conversation[replaceIndex] = rec" in body, "the in-place replace"
assert "conversation.push(rec)" in body, "the append (no partial / guard miss)"
i_replace = body.index("conversation[replaceIndex] = rec")
i_push = body.index("conversation.push(rec)")
assert i_replace < i_push, "replace branch precedes the append fallback"
i_save = body.index("saveConversation()")
i_persist = body.index("persistConversation()")
assert i_push < i_save < i_persist, (
"both save points run ONCE, after the single write — the auto-save "
"refreshes the row exactly once for the replaced record"
)
def test_every_settle_writes_through_the_correlation() -> None:
"""The three brain-record write sites in runTurn's settle paths —
the `done` save point, the zero-frame empty-answer fallback, and the
stop-path partial — all pass `leavePartialIndex`, so the replace is
the only write after a pagehide partial (the invariant). The
pagehide handler itself pushes with the plain two-arg form (it IS
the partial)."""
js = _js()
# done save point.
done = js.find('ev.type === "done"')
done_branch = js[done: js.find('ev.type === "error"', done)]
assert "leavePartialIndex" in done_branch, "the done settle is correlated"
# zero-frame empty-answer fallback.
fallback = js.find("!aborted && !wrap")
fallback_block = js[fallback: js.find(") catch (err) {", fallback)]
assert "leavePartialIndex" in fallback_block, "the fallback settle is correlated"
# stop-path partial (the `stopped` marker persist).
catch = js.find("} catch (err) {")
stop_idx = js.find('stoppedByUser || err?.name === "AbortError"', catch)
stop_branch = js[stop_idx: js.find("} else {", stop_idx)]
assert "leavePartialIndex" in stop_branch, "the stop settle is correlated"
# The pagehide handler pushes the plain two-arg partial (the record
# that the settles replace).
fn = js.find('window.addEventListener("pagehide"')
body = js[fn: js.find("\n});", fn)]
assert "rememberBrainTurn(acc, { thinking: thinkingAcc || undefined });" in body
assert "leavePartialIndex" not in body.replace(
"leavePartialIndex = conversation.length - 1", ""
), "the pagehide handler only records the index — it never settles"
def test_real_navigation_behavior_is_unchanged() -> None:
"""The leave-save invariant: a REAL navigation never runs a settle
(the page unloads), so the pagehide partial stays persisted exactly
as phase 20 left it — the handler's guards and the rememberBrainTurn
ride-through (localStorage + auto-save) are untouched."""
js = _js()
fn = js.find('window.addEventListener("pagehide"')
body = js[fn: js.find("\n});", fn)]
for guard in (
"if (persistedOnLeave) return;",
"if (uiState !== UI_STATE.thinking && uiState !== UI_STATE.streaming)",
"if (!acc) return;",
):
assert guard in body, f"phase-20 guard intact: {guard}"
# The phase-48 teardown contract (real close / navigation / Stop still
# aborts the fetch) is untouched.
assert "turnAbort?.abort()" in js, "the abort owner still aborts the fetch"
assert re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*120_?000\s*;", js), (
"the guard constant is still 120s (owner-locked phase-17/48 value)"
)
# ---------- C2: hidden time does not count toward the pre-token guard ----------
def test_guard_arms_with_a_rearmable_callback() -> None:
"""armTurnTimeout remembers the guard's callback (turnTimeoutCb) so
the visibility listener can re-arm it; clearTurnTimeout clears BOTH
the timer and the callback — armed ⇔ callback set."""
js = _js()
fn = js.find("function armTurnTimeout")
assert fn != -1, "armTurnTimeout must exist"
body = js[fn: js.find("\n}\n", fn)]
assert "turnTimeoutCb = onTimeout" in body, "the callback is remembered"
assert body.index("turnTimeoutCb = onTimeout") < body.index(
"turnTimeout = setTimeout(onTimeout, TURN_TIMEOUT_MS)"
), "the callback is stored with the arm"
cfn = js.find("function clearTurnTimeout")
cbody = js[cfn: js.find("\n}\n", cfn)]
assert "clearTimeout(turnTimeout)" in cbody
assert "turnTimeout = 0" in cbody
assert "turnTimeoutCb = null" in cbody, "clearing the timer drops the callback"
def test_visibility_rearm_counts_only_visible_pre_token_time() -> None:
"""On visibilitychange, when the tab returns to VISIBLE with the
guard still armed (the pre-token window — the timer id is non-zero
and the callback set), it re-arms with a FRESH TURN_TIMEOUT_MS via
armTurnTimeout (the same arm path — one timer, one owner). Hidden
transitions never re-arm. Exactly one such listener exists."""
js = _js()
assert js.count('document.addEventListener("visibilitychange"') == 1, (
"one visibility listener (the guard re-arm)"
)
fn = js.find('document.addEventListener("visibilitychange"')
body = js[fn: js.find("});", fn)]
assert 'document.visibilityState === "visible"' in body, (
"only a return to visible re-arms — hidden transitions do not extend it"
)
assert "turnTimeout && turnTimeoutCb" in body, (
"only an ARMED guard (pre-token window) is re-armed — the timer clears "
"on the first thinking/delta/retry frame and every terminal transition"
)
assert "armTurnTimeout(turnTimeoutCb)" in body, (
"the re-arm goes through the same arm path (a fresh TURN_TIMEOUT_MS)"
)
assert "TURN_TIMEOUT_MS" in body or "armTurnTimeout" in body, (
"the fresh deadline is the owner-locked TURN_TIMEOUT_MS"
)
def test_header_documents_the_phase_73_contract() -> None:
"""House convention: the file-header doc inventories each phase's
contract — phase 73 documents the hidden-tab rule (only close /
navigation / Stop aborts) and both hardenings."""
js = _js()
header = js[: js.find("import {")]
assert "phase 73" in header.lower(), "the header must carry the phase-73 section"
assert "leavePartialIndex" in header, "the correlation local is documented"
assert "visibilitychange" in header, "the visibility re-arm is documented"