feat(chat): invalidate saved chats on sources sync — versioned stamps, stale marker, Regenerate against the new index

This commit is contained in:
2026-08-30 23:39:15 -04:00
parent ea8e041189
commit 32b7bfd4b3
26 changed files with 2145 additions and 63 deletions
+98 -17
View File
@@ -17,7 +17,13 @@ regression is caught without a browser:
* ``#nav-history`` on ALL SEVEN pages (the phase-34 one-bar contract)
+ ``header.js``'s reveal-for-admin block;
* the full-width table CSS (AGENTS.md rule 5) + the confirm pair +
the empty-state row.
the empty-state row;
* the Stale column (phase 53, task 04): the READ-ONLY marker cell in
``makeRow`` (the rose ``.stale-pill`` from the row's ``stale`` flag
+ the em-dash fallback, the ``<td>`` aria-label in BOTH states —
WCAG 2.1 AA, conveyed without the visual), the ``Stale`` ``<th>``
between Updated and Share in ``history.html``, and the ``.stale-pill``
rose-family CSS in ``styles.css``.
The Containerfile stage-1 coverage (history.html copied, history.js
bundled) is pinned dynamically by
@@ -177,35 +183,39 @@ def test_history_page_scaffold_and_landmarks() -> None:
def test_history_table_skeleton() -> None:
"""The table skeleton: ``.history-table`` with the five columns —
Title | Messages | Updated | Share (phase 51) | Actions (the
Actions header text is visually-hidden — the row buttons carry
their own aria-labels) — and the empty-state row (ship-hidden, the
exact copy)."""
"""The table skeleton: ``.history-table`` with the six columns —
Title | Messages | Updated | Stale (phase 53) | Share (phase 51) |
Actions (the Actions header text is visually-hidden — the row
buttons carry their own aria-labels) — and the empty-state row
(ship-hidden, the exact copy)."""
html = _text(HISTORY_HTML)
assert '<table class="history-table">' in html
for col in ('<th scope="col">Title</th>', '<th scope="col">Messages</th>',
'<th scope="col">Updated</th>', '<th scope="col">Share</th>'):
'<th scope="col">Updated</th>', '<th scope="col">Stale</th>',
'<th scope="col">Share</th>'):
assert col in html
# The Share column sits BETWEEN Updated and Actions.
# The Stale column (phase 53) sits BETWEEN Updated and Share —
# i.e. between Updated and Actions — so the phase-51 contract
# (Share between Updated and Actions) still holds.
assert (
html.find('<th scope="col">Updated</th>')
< html.find('<th scope="col">Stale</th>')
< html.find('<th scope="col">Share</th>')
< html.find('visually-hidden">Actions')
), "the Share column must sit between Updated and Actions"
), "the Stale column must sit between Updated and Share"
actions_th = re.search(
r'<th scope="col">([^<]*)<span class="visually-hidden">Actions</span></th>',
html,
)
assert actions_th, "the Actions column header must be visually-hidden text"
assert actions_th.group(1) == "", "no visible text beside the hidden header"
# The empty-state row: ship-hidden, colspan 5 (the Share column
# joined the table in phase 51), the exact copy.
# The empty-state row: ship-hidden, colspan 6 (phase 51 added
# Share, phase 53 added Stale), the exact copy.
row = re.search(r'<tr[^>]*class="history-empty-row"[^>]*>', html)
assert row, "the empty-state row must ship in the skeleton"
assert "hidden" in row.group(0)
assert 'id="history-empty-row"' in row.group(0)
assert "<td colspan=\"5\">" in html
assert "<td colspan=\"6\">" in html
assert (
"No saved chats yet — finish a conversation and press"
" <strong>Save</strong> in the chat."
@@ -446,20 +456,91 @@ def test_history_table_mobile_behavior() -> None:
def test_make_row_inserts_share_cell_between_updated_and_actions() -> None:
"""makeRow: the Share <td> (with the share control) lands BETWEEN
the Updated cell and the Actions cell — the column order in
history.html is Title | Messages | Updated | Share | Actions."""
history.html is Title | Messages | Updated | Stale (phase 53) |
Share | Actions."""
js = _js()
row = _fn(js, "makeRow")
updated_i = row.find('updatedTd.className = "history-updated-cell"')
stale_i = row.find('staleTd.className = "history-stale-cell"')
share_i = row.find('shareTd.className = "history-share-cell"')
actions_i = row.find('actionsTd.className = "history-actions-cell"')
assert -1 < updated_i < share_i < actions_i, (
"the share cell must sit between Updated and Actions"
assert -1 < updated_i < stale_i < share_i < actions_i, (
"the stale cell (phase 53) must sit between Updated and Share —"
" i.e. the share cell must still sit between Updated and Actions"
)
assert "makeShareControl(chat)" in row
seq = re.findall(r"tr\.appendChild\((\w+)\)", row)
assert seq == ["titleTd", "countTd", "updatedTd", "shareTd", "actionsTd"], (
f"row cell order must be title/count/updated/share/actions, got {seq}"
assert seq == [
"titleTd", "countTd", "updatedTd", "staleTd", "shareTd", "actionsTd",
], f"row cell order must be title/count/updated/stale/share/actions, got {seq}"
# ---------- the Stale column (phase 53, task 04) ----------
def test_stale_cell_branches_on_row_flag_with_aria_label() -> None:
"""makeRow (phase 53 task 04): the Stale cell renders from the
row's ``stale`` flag — the SERVER computes staleness (task 03),
the client never does version math. Stale rows: the rose
``.stale-pill`` (the ``Stale`` text + the EXACT hover copy pointing
at the Regenerate action on the chat page, task 05). Fresh rows:
a plain em-dash (no pill). The <td> carries its own aria-label in
BOTH states (WCAG 2.1 AA — the marker must be conveyed without the
visual). READ-ONLY badge: the cell binds no events and creates no
controls (the Regenerate button lives on the chat-page banner);
textContent only (XSS contract)."""
js = _js()
row = _fn(js, "makeRow")
assert 'staleTd.className = "history-stale-cell"' in row
assert "tr.appendChild(staleTd)" in row
assert "if (chat.stale)" in row, "the cell branches on the row's stale flag"
branch = row[row.find("if (chat.stale)") : row.find("tr.appendChild(staleTd)")]
# The stale branch: the rose pill with the exact hover copy.
assert 'pill.className = "stale-pill"' in branch
assert 'pill.textContent = "Stale"' in branch
assert (
'pill.title = "Sources have changed since this chat was saved'
" — open the chat to Regenerate\";"
) in branch, "the pill's hover copy points at the Regenerate action (task 05)"
# The fresh branch: the plain em-dash, never the pill.
else_i = branch.find("} else {")
assert else_i != -1, "the fresh branch must exist"
fresh = branch[else_i:]
assert 'staleTd.textContent = "—"' in fresh, "fresh rows render the em-dash"
assert "stale-pill" not in fresh, "fresh rows render the em-dash, not the pill"
# The <td> aria-label ships in BOTH states (conveyed without the
# visual — WCAG 2.1 AA).
assert branch.count('staleTd.setAttribute("aria-label"') == 2, (
"the cell's aria-label must exist in the stale AND the fresh branch"
)
# READ-ONLY: no events, no controls, no innerHTML anywhere in the
# cell's construction.
assert "addEventListener" not in branch
assert "createElement(\"button\")" not in branch
assert "innerHTML" not in branch
def test_stale_column_css_rose_family() -> None:
"""styles.css (phase 53 task 04): ``.stale-pill`` is the rose
family — the Stop-treatment tokens (err-ink on err-bg ≈9.3:1, the
err-line border), theme-token based so it stays AA with the
palette; a compact rounded pill (border-radius 999px, nowrap). The
``.history-stale-cell`` keeps the marker on one line and rides
ink-soft (5.1:1 on --surface) for the fresh rows' em-dash."""
css = _css()
pill = re.search(r"\.stale-pill \{([\s\S]*?)\n\}", css)
assert pill, "styles.css must style .stale-pill"
body = pill.group(1)
assert "background: var(--err-bg)" in body, "the Stop-treatment tokens"
assert "color: var(--err-ink)" in body
assert "border: 1px solid var(--err-line)" in body
assert "border-radius: 999px" in body, "the pill shape"
assert "white-space: nowrap" in body
cell = re.search(r"\.history-stale-cell \{([^}]*)\}", css)
assert cell, "the stale cell must be styled"
cbody = cell.group(1)
assert "white-space: nowrap" in cbody
assert "var(--ink-soft)" in cbody, "the em-dash rides ink-soft (AA on --surface)"
def test_share_control_three_states_and_two_step_unshare() -> None:
+224
View File
@@ -447,3 +447,227 @@ def test_share_button_revealed_only_for_admin() -> None:
assert boot_start < save_reveal < reveal, (
"the Share reveal joins the same admin-reveal block as Save"
)
# ---------- stale saved chat: banner + Regenerate (phase 53, task 05) ----------
def test_stale_banner_html_after_kb_banner() -> None:
"""index.html: the #stale-banner section sits DIRECTLY AFTER
#kb-banner (the chat-shell top-of-column position — kb-banner keeps
the top slot when both are visible), role="status", shipped hidden,
with the exact text and the #stale-regenerate button (type=button,
visible label "Regenerate", the redo glyph — the SAME SVG paths as
RETRY_ICON in app.js, the phase-49 Retry asset). No other page
carries it (chat-page only)."""
html = _index()
kb_idx = html.find('id="kb-banner"')
banner = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
assert banner, "index.html must contain the #stale-banner section"
tag = banner.group(0)
assert 'role="status"' in tag
assert "hidden" in tag, "the banner ships hidden (the reveal is app.js's job)"
assert -1 < kb_idx < banner.start(), "the banner sits directly after #kb-banner"
# Nothing between the kb-banner close and the stale banner except
# whitespace + the phase-53 comment: the top-of-column pair is kept.
between = html[html.find("</div>", kb_idx) : banner.start()]
assert "id=" not in between, "no other element lands between the two banners"
block = html[banner.start() : html.find("</section>", banner.start())]
assert "The sources have been updated since this chat was saved." in block
btn = re.search(r'<button[^>]*id="stale-regenerate"[^>]*>', block)
assert btn, "the banner carries the #stale-regenerate button"
assert 'type="button"' in btn.group(0)
btn_block = block[btn.start() : block.find("</button>", btn.start())]
assert ">Regenerate</span>" in btn_block, "the visible label is Regenerate"
# The redo glyph: the SAME paths as RETRY_ICON (the phase-49 asset).
assert 'd="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"' in btn_block
assert 'd="M21 3v5h-5"' in btn_block
# The banner's own leading mark is the redo glyph too (distinct from
# the kb-banner warning triangle) — aria-hidden decoration.
lead = block[: btn.start()]
assert 'aria-hidden="true"' in lead and 'd="M21 3v5h-5"' in lead
for other in (SOURCES_HTML, GIT_SOURCES_HTML, DOCUMENT_HTML, LOGIN_HTML,
TUNING_HTML, Path(FRONTEND / "history.html")):
assert 'id="stale-banner"' not in other.read_text(encoding="utf-8"), (
f"{other.name}: the stale banner is chat-page only"
)
def test_boot_load_reveals_stale_banner_on_payload_stale() -> None:
"""restoreSavedChatFromUrl: on a 200 payload with stale: true, the
#stale-banner is revealed (hidden removed) — the flag is
server-computed (task 03), the client never does staleness math.
The reveal rides the boot SUCCESS path (after the link + the
localStorage mirror), so a non-stale payload leaves the banner
hidden."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
assert "data.stale === true" in body, "the reveal branches on the payload's stale flag"
link_i = body.find("currentChatId = chatId")
reveal_i = body.find("staleBanner.hidden = false")
assert -1 < link_i < reveal_i, "the reveal runs on the success path, after the link"
assert "staleBanner.hidden = false" in body, "reveal = remove `hidden`"
def test_boot_load_stale_reveal_no_brain_record_guard() -> None:
"""The no-brain-record guard: a stale conversation with NO brain
record (user-only) is revealed TEXT-ONLY — the #stale-regenerate
button is removed BEFORE the reveal (retryLastTurn is never called
in that state)."""
js = _js()
body = _fn(js, "restoreSavedChatFromUrl")
guard_i = body.find('!conversation.some((m) => m.who === "brain")')
remove_i = body.find("staleRegenBtn.remove()")
reveal_i = body.find("staleBanner.hidden = false")
assert -1 < guard_i < remove_i < reveal_i, (
"the no-brain check removes the button before the banner is revealed"
)
def test_retry_last_turn_returns_the_turn_promise() -> None:
"""retryLastTurn RETURNS the runTurn promise (phase 53 task 05):
the Regenerate path awaits the turn's completion to know when to
persist. The phase-49 Retry click handler ignores the return value
— behavior-neutral for it (the redo-order pins in
test_frontend_feedback.py keep holding unchanged)."""
js = _js()
body = _fn(js, "retryLastTurn")
assert "return runTurn(text, { reask: true })" in body, (
"the redo promise is returned for the Regenerate await"
)
assert "void runTurn" not in body, "the fire-and-forget void is gone"
# The existing Retry click handler still ignores the return value.
append = _fn(js, "appendRetryButton")
assert "retryLastTurn(wrap)" in append, "the Retry click is unchanged (no await)"
def test_stale_regenerate_drives_retry_last_turn_and_awaits() -> None:
"""regenerateStaleChat: drives retryLastTurn on the LAST brain
bubble's rendered wrap (phase-49 targeting — retryLastTurn's own
`wrap !== lastBrainWrap` guard makes a stale click a no-op that
resolves nothing), AWAITs the returned turn promise, and persists
only when the turn completed WITHOUT the error banner (a
mid-stream error leaves the linked row untouched — stale stays
true). The double-click guard releases in the finally — never
stale (PLAN §7.4)."""
js = _js()
body = _fn(js, "regenerateStaleChat")
assert "staleRegenBtn.disabled = true" in body, "one regenerate at a time"
call_i = body.find("retryLastTurn(lastBrainWrap)")
await_i = body.find("await turn")
assert -1 < call_i < await_i, "call the redo on the last brain wrap, then await it"
err_i = body.find('banner.classList.contains("is-error")')
put_i = body.find('`/api/chats/${currentChatId}`')
assert -1 < await_i < err_i < put_i, (
"the error-banner check sits between the await and the persist"
)
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "staleRegenBtn.disabled = false" in body[finally_idx:], (
"the button is re-enabled in the finally — never stale"
)
def test_stale_regenerate_persists_the_linked_row() -> None:
"""The post-regenerate persist (the existing upsert path): linked →
PUT /api/chats/<id> (the server re-stamps sources_version → the row
is fresh); a 404 (the row was deleted from History meanwhile)
follows saveCurrentChat's stale-link rule — unlink + recreate
(POST), and the recreate links the new id. Success hides the banner
AND announces the outcome in the #send-status live region; 403/5xx
→ the actionable error banner (the row stays as the turn left it);
network → the reachable? banner."""
js = _js()
body = _fn(js, "regenerateStaleChat")
assert "if (currentChatId)" in body
assert 'method: "PUT"' in body
assert 'fetch("/api/chats"' in body and 'method: "POST"' in body
put_idx = body.find('method: "PUT"')
post_idx = body.find('method: "POST"')
assert -1 < put_idx < post_idx, "the PUT (linked) branch precedes the POST fallback"
assert '{ messages: conversation }' in body, "the messages payload — no title (keep current)"
# The 404→recreate fallback: unlink, then POST again.
notfound_idx = body.find("res.status === 404")
assert notfound_idx != -1, "the PUT 404 must be handled"
fallback = body[notfound_idx:post_idx]
assert "currentChatId = null" in fallback, "the stale link is dropped"
# The recreate links the new row.
assert "res.status === 201" in body
assert "currentChatId = String(created.id)" in body
# Success: hide the banner, then announce in the live region.
hide_i = body.find("staleBanner.hidden = true")
ann_i = body.find('sendStatus.textContent = "Regenerated')
assert -1 < hide_i < ann_i, "hide the banner, then announce the outcome"
assert "the answer now reflects the current sources." in body, ("the live-region line")
# Failures raise an actionable banner (non-ok HTTP + network).
assert (
'showErrorBanner("Couldn\'t save the regenerated answer — is the app reachable?")' in body
)
assert "check you're still signed in and try again" in body, "403/5xx: actionable line"
def test_stale_regenerate_binding_and_element_queries() -> None:
"""The wiring: app.js queries #stale-banner + #stale-regenerate at
module scope and binds the click to regenerateStaleChat. The banner
only ever shows on the /?chat=<id> boot path (admin), so the
binding is inert otherwise."""
js = _js()
assert 'document.querySelector("#stale-banner")' in js
assert 'document.querySelector("#stale-regenerate")' in js
assert 'staleRegenBtn?.addEventListener("click", regenerateStaleChat)' in js
def test_stale_banner_cleared_on_new_chat_and_resave() -> None:
"""Never-stale (PLAN §7.4): "New chat" replaces the conversation the
banner described (and unlinks it) — the banner hides; a successful
manual re-Save re-stamps the row to the current generation (task
03) — the banner is done the moment the save succeeds."""
js = _js()
new_body = _fn(js, "startNewChat")
assert "staleBanner.hidden = true" in new_body, "New chat hides the banner"
save_body = _fn(js, "saveCurrentChat")
saved_line = 'sendStatus.textContent = "Conversation saved."'
after = save_body[save_body.find(saved_line):]
assert "staleBanner.hidden = true" in after, (
"a successful re-save re-stamps the row — the banner is done"
)
def test_stale_banner_css_is_the_kb_banner_family() -> None:
"""styles.css: the banner rides the .kb-banner family (the section
carries BOTH classes — the flex row + accent tokens come from
.kb-banner; .stale-banner adds the wrap so the pill can drop below
the text when the row must wrap), and the .stale-regenerate pill is
the EXACT brand-pill family of Save/Share (solid --brand, --bg text
5.2:1 AA, borderless, 999px, ≥44px, hover lightens the fill, 16px
redo glyph). The ≤640px block makes the pill a full-width row."""
html = _index()
tag = re.search(r'<section[^>]*id="stale-banner"[^>]*>', html)
assert tag and "kb-banner" in tag.group(0) and "stale-banner" in tag.group(0), (
"the section carries both classes — the family comes from .kb-banner"
)
css = _css()
block = re.search(r"\.stale-regenerate \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .stale-regenerate"
body = block.group(1)
for prop in (
"display: inline-flex",
"min-height: 44px",
"margin-left: auto",
"border-radius: 999px",
"border: 0",
"background: var(--brand)",
"color: var(--bg)",
"font-weight: 700",
"cursor: pointer",
):
assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})"
hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css)
assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill"
assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), (
"the redo glyph rides the 16px pill size"
)
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "mobile media query missing"
assert ".stale-regenerate { margin-left: 0; width: 100%; }" in mobile.group(1), (
"at phone width the pill takes a full-width row"
)
+101
View File
@@ -0,0 +1,101 @@
"""Unit: sources-version counter helpers (phase 53, task 01).
``app.rag.sources_meta`` runs against the local compose Postgres
(``podman compose up -d db``) — the house DB-test pattern: the helpers
are thin session wrappers whose contract (seeded single row, flush-not
commit, defensive absence) only holds against a real database. Skips
with clear instructions when the stack is not up.
A fixture resets ``sources_meta`` to the migration-0010 seed state
(id 1, version 0) before and after every test, so the suite leaves the
dev DB exactly as the migration left it.
"""
from __future__ import annotations
import pytest
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import SourcesMeta
from app.rag.sources_meta import bump_sources_version, current_sources_version
@pytest.fixture()
def seeded_sources_meta(db: Session):
"""Reset the counter to the migration-0010 seed (id 1, version 0)."""
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
yield
db.execute(text("DELETE FROM sources_meta"))
db.add(SourcesMeta(id=1, version=0))
db.commit()
def test_current_absent_row_returns_zero_without_raising(
db: Session, seeded_sources_meta: None
) -> None:
"""Defensive: a deleted seed row reads as 0 — never an exception."""
db.execute(text("DELETE FROM sources_meta"))
db.commit()
assert current_sources_version(db) == 0
def test_first_bump_zero_to_one(db: Session, seeded_sources_meta: None) -> None:
"""Seeded at 0 (the pre-counter KB), the first bump returns 1 and
``current`` reflects it."""
assert current_sources_version(db) == 0
assert bump_sources_version(db) == 1
db.commit() # the caller commits — the helper only flushes
assert current_sources_version(db) == 1
def test_second_bump_increments(db: Session, seeded_sources_meta: None) -> None:
"""Bumps are monotonic: 0 → 1 → 2, one step per KB-changing sync."""
assert bump_sources_version(db) == 1
db.commit()
assert bump_sources_version(db) == 2
db.commit()
assert current_sources_version(db) == 2
def test_bump_absent_row_upserts_to_one(db: Session, seeded_sources_meta: None) -> None:
"""Upsert semantics: a deleted seed row is recreated by the first
bump (version 0 → 1), never left dangling."""
db.execute(text("DELETE FROM sources_meta"))
db.commit()
assert bump_sources_version(db) == 1
db.commit()
row = db.get(SourcesMeta, 1)
assert row is not None, "the single row must be recreated"
assert row.id == 1
assert row.version == 1
assert row.updated_at is not None, "updated_at must be server-stamped"
def test_bump_flushes_without_committing(db: Session, seeded_sources_meta: None) -> None:
"""The helper flushes, it does not commit: a rolled-back session
must roll the bump back with it (each sync path owns its
transaction)."""
assert bump_sources_version(db) == 1
db.rollback()
assert current_sources_version(db) == 0, "the uncommitted bump must roll back"
def test_bumps_in_separate_sessions_progress(db: Session, seeded_sources_meta: None) -> None:
"""One writer at a time is the deployment reality, but two bumps in
two sessions must not race to the same value: the second session
sees the committed increment and lands on the next generation."""
assert bump_sources_version(db) == 1
db.commit()
other = SessionLocal()
try:
assert bump_sources_version(other) == 2
other.commit()
finally:
other.close()
db.expire_all() # drop the stale identity-map state
assert current_sources_version(db) == 2