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
+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"