fix(chat): keep in-flight answers alive across in-app view switches
Root cause (owner repro, verified in a real browser 2026-09-06): the five navbar views (Chat, RAG, Sources, Tuning, History) were separate HTML documents, so a navbar click was a REAL cross-document navigation — the chat page unloaded, the in-flight SSE fetch was aborted, and the phase-48 teardown (app/api/chat.py `finally`, "chat: turn cancelled") stopped the model. Observed: send question -> click RAG mid-stream -> click Chat -> the answer never finished: no `query_log` row, and on return a dangling question with no brain record (the pre-token pagehide partial persist skips because `acc` is empty). Phase-48 LOCKED-DECISION REFINEMENT (owner-confirmed 2026-09-06, flagged per AGENTS.md rule 3, not silently deviated): "real navigation cancels the fetch" now means LEAVING THE APP — tab close, external/other-document navigation, the Stop button. In-app navbar switches are client-side view switches and no longer cancel. Fix — Option A (SPA shell), chosen over B (Service Worker owns the stream) and C (server-side turn registry + resume): - frontend/index.html is the shell: ONE `<main id="main">` holds the five `<section class="view">` blocks; hidden views carry BOTH `hidden` and `inert` (WCAG — no focus/keyboard traversal). The shared header, the single `doc-modal-*` skeleton, and the `#app-version` footer each exist exactly once; the per-view copies from the four folded pages are dropped. - New frontend/assets/router.js (vanilla module — no framework, no bundler, No-CDN rule intact): lazy-imports a view module on FIRST show only (mount-once, hide-forever — the chat view's in-flight SSE reader persists across switches; that persistence IS the fix); intercepts same-shell navbar links with preventDefault + history.pushState (never a document load); handles popstate; single writer of `.nav-link` active state (is-active + aria-current), document.title, and the per-view meta description (values carried over from the old pages' heads, brand-resolved at write time). - Each folded page's JS becomes `export async function mount(root)` — root-scoped queries; `initSharedHeader()` dropped (the header boots once in the shell via the chat module; the admin flag comes from the same cached `fetchIsAdmin()` promise — zero extra requests). - app/main.py: a small list-driven route factory serves the shell for /tuning.html, /sources.html, /git-sources.html, /history.html — registered AFTER the API routers and BEFORE the static catch-all (routes-first). The phase-33 caching middleware applies no-cache + `?v=` rewriting unchanged; app/core/caching.py needed NO change (the view paths did not change — pinned by the integration tests). - The four old view .html files are DELETED (one source of truth); deep links to the old URLs keep working (the router picks the view from the pathname); `/?chat=<id>` is unaffected; the Containerfile bundles router.js (inlining the lazy view modules) and drops the folded page files. - app/schemas.py: HistoryTurn.text cap 4000 -> 32000 — the shell keeps long saved answers in the chat, and the old cap (stricter than the 24_000-char total history budget) 422-rejected any second turn in such a chat (found by the phase-42 E2E suite on the shell). Boundaries: login.html, shared.html, doc-edit.html, document.html REMAIN separate documents (flow pages, not navbar tabs); a mid-stream navigation to doc-edit/document.html still cancels per phase 48 (follow-up candidate, out of scope). The SSE API is unchanged. Real departures still cancel the turn — phase 48 intact (pinned by tests/e2e/test_stop_generation.py, unchanged, and by the new suite's real-departure control). Tests: - Phase-20 suite REWRITTEN to the new semantics (tests/e2e/test_sources_midstream_bug.py): a navbar switch no longer cancels — the stream survives the switch and the FULL answer settles; the pagehide partial persist REMAINS for real departures (the partial's exact shape — first streamed chunk prefix, no done metadata — is still pinned there). - NEW story suite tests/e2e/test_nav_switch_keeps_stream.py (mock LLM): the owner repro (send -> RAG mid-stream -> Chat: window sentinel survives = same document, FULL answer, exactly one brain turn in bor.chat.v1, exactly one settled query_log row, auto-saved row matches) + the same mid-stream switch against the other three views + the real-departure-still-cancels control + the no-switch baseline. - tests/unit/test_frontend_router.py: source-level pins of the router invariants (click interceptor targets ONLY same-shell view paths, pushState-only switches, mount-once guard, hidden+inert pair, single-writer active state/title); shell-route integration tests (each folded path serves the shell with no-cache + `?v=` body; a non-view path still 404s); the file-reading unit pins re-pointed at the shell (the four view files are gone — the shell is the source of truth). Verification (this commit): full suite green — 1565 unit+integration tests, app/ coverage 99% (>90% floor); ruff + pyright clean; the phase's E2E suites green in isolation (house protocol, AGENTS.md rule 9). Owner repro verified in a real browser against the real LLM (dev server :8010, headful Chromium): "tell me about everquest" -> RAG mid-stream -> Chat — the answer completed with one brain bubble and no error banner, `query_log` gained exactly one settled row (deflected=True: the dev KB holds no EverQuest docs — the settle, not the topic, is the proof), zero "chat: turn cancelled" lines for that turn; the control (real navigation to /shared.html mid-stream) still cancelled (no settled row, the cancel line logged, the partial persisted on return). Screenshots: .agents/screenshots/76_manual_*. Phase 76 (76_spa_nav_shell) complete — moved to .agents/phases/complete/.
This commit is contained in:
@@ -72,6 +72,18 @@ Test → story mapping (Playwright Mapping Rule):
|
||||
4. ``test_viewer_back_link_honors_back_param``
|
||||
5. ``test_steering_surface_off_chat_on_tuning_page``
|
||||
6. ``test_sync_button_present_on_sources_page_without_triggering``
|
||||
7. ``test_viewer_nav_click_full_loads_the_shell_rag_view`` (phase 76
|
||||
task 04 — the header is shell-owned: the surviving standalone
|
||||
documents keep their header copies, and a navbar click on one is a
|
||||
REAL departure that full-loads the shell, whose router renders the
|
||||
target view from the pathname)
|
||||
|
||||
Phase 76 adaptation (tasks 01–03): chat / sources / tuning are VIEWS of
|
||||
ONE shell document (index.html) — the header under test on those URLs is
|
||||
the shell's single one (the router deep-links the view from the
|
||||
pathname on each real goto). The per-URL inventory comparison is the
|
||||
pre-phase-76 probe, kept verbatim; test 7 adds the surviving-document
|
||||
side of the ownership boundary (the viewer's own header copy).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -243,15 +255,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
assert page.locator("#steering-toggle").count() == 0, (
|
||||
f"{name}: the steering toggle was removed from the navbar"
|
||||
)
|
||||
# The Sync button is a page-specific control (Sources page only
|
||||
# — owner rework 2026-08-28): visible on sources, absent from
|
||||
# the shared bar everywhere else.
|
||||
# The Sync button is a view-specific control (RAG view only —
|
||||
# owner rework 2026-08-28): visible on sources, not visible
|
||||
# anywhere else. Phase 76 (task 02): in the shell the RAG view
|
||||
# (with the button) is in the DOM on every view — hidden +
|
||||
# inert — so the pin is VISIBLE, not ABSENT (to_be_hidden()
|
||||
# also passes on standalone pages where the button is absent).
|
||||
if name == "sources":
|
||||
expect(page.locator("#sync-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#sync-btn").count() == 0, (
|
||||
f"{name}: #sync-btn left the shared bar (Sources page only)"
|
||||
)
|
||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||
else:
|
||||
@@ -277,14 +290,16 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
||||
assert page.locator("#steering-panel").count() == 0, (
|
||||
f"{name}: the steering panel must be absent for anonymous"
|
||||
)
|
||||
# The New chat button is chat-page only (moved from the shared bar
|
||||
# to index.html's .chat-shell at owner request, 2026-08-28).
|
||||
# The New chat button is chat-view only (moved from the shared bar
|
||||
# to the shell's .chat-shell at owner request, 2026-08-28).
|
||||
# Phase 76 (task 02): in the shell the chat view (with the button)
|
||||
# is in the DOM on every view — hidden + inert — so the pin is
|
||||
# VISIBLE, not ABSENT (to_be_hidden() also passes on standalone
|
||||
# pages where the button is absent).
|
||||
if name == "chat":
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
else:
|
||||
assert page.locator("#new-chat-btn").count() == 0, (
|
||||
f"{name}: #new-chat-btn left the shared bar (chat page only)"
|
||||
)
|
||||
expect(page.locator("#new-chat-btn")).to_be_hidden()
|
||||
|
||||
_assert_landmarks(page, name)
|
||||
return _header_inventory(page)
|
||||
@@ -540,3 +555,61 @@ def test_sync_button_present_on_sources_page_without_triggering(
|
||||
assert btn.get_attribute("aria-busy") is None, "a fresh idle sync must not be busy"
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
# Deliberately NOT clicked.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Phase 76 task 04: the surviving documents keep their header copies —
|
||||
# a navbar click on one is a REAL departure that full-loads the shell
|
||||
# (whose router then renders the target view from the pathname)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_viewer_nav_click_full_loads_the_shell_rag_view(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
"""Phase 76 task 04: the document viewer is one of the four surviving
|
||||
standalone documents (login, shared, doc-edit, document) — it keeps
|
||||
its own header copy, and the router does NOT run there (the router
|
||||
lives in the shell). Clicking its RAG nav link is therefore a REAL,
|
||||
document-level navigation — not the shell's pushState switch: it
|
||||
full-loads the shell at /sources.html, and the shell's router renders
|
||||
the RAG view from the pathname (the Chat view ships hidden + inert
|
||||
inside that same document).
|
||||
|
||||
The window sentinel proves the departure in the phase-76 canonical
|
||||
form, used in INVERSE: it is set in the viewer document and must be
|
||||
GONE after the click (a real load wipes window globals — exactly
|
||||
what distinguishes a departure from the shell's same-document
|
||||
switches, where the sentinel survives)."""
|
||||
page.set_viewport_size({"width": 1280, "height": 800})
|
||||
_seed_db(mock_llm)
|
||||
|
||||
# Admin: the viewer's RAG nav link is admin-only (revealed by the
|
||||
# whoami pass of its own header copy).
|
||||
login(page, app_url, next=CHAT_URL)
|
||||
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
||||
page.goto(app_url + VIEWER_URL)
|
||||
expect(page.locator("#doc-title")).to_have_text(DOC_TITLE, timeout=15_000)
|
||||
_wait_settled(page, admin=True)
|
||||
expect(page.locator("#nav-sources")).to_be_visible()
|
||||
|
||||
# The sentinel lives in the VIEWER document only.
|
||||
page.evaluate("() => { window.__phase76_viewer = 'phase76'; }")
|
||||
page.click("#nav-sources")
|
||||
|
||||
# The arrival is the SHELL at the RAG view's URL: the document loaded
|
||||
# for real (the sentinel is gone), the RAG view is rendered (first
|
||||
# table row visible), the Chat view is hidden AND inert in the same
|
||||
# document, and the RAG link carries the router's single-writer
|
||||
# active stamp.
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
assert page.evaluate("() => window.__phase76_viewer") is None, (
|
||||
"a surviving document's nav click must be a real departure "
|
||||
"(a fresh document load wipes window globals)"
|
||||
)
|
||||
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#view-chat")).to_be_hidden()
|
||||
assert page.evaluate("() => document.getElementById('view-chat').inert") is True, (
|
||||
"the chat view ships hidden AND inert in the shell"
|
||||
)
|
||||
expect(page.locator("#nav-sources")).to_have_class(re.compile(r"\bis-active\b"))
|
||||
|
||||
Reference in New Issue
Block a user