phase: 189_chat_save_long_turns
Build and Push Containers / build-and-push-app (push) Successful in 3m4s
Build and Push Containers / build-and-push-db (push) Successful in 12s

**Phase 189 final verification — all green**

- **Verified (all 4 tasks already implemented):** A1 caps in `app/schemas.py` — `sources`/`related`/`tools` 200, `suggestions` 100, `segments` 450; `text`/`thinking` 600 000, `error`/`image` 500, `answer_text` 50, `messages` 500, 8 M budget all UNCHANGED; rationale docstrings rewritten to `merge_cited_docs`/`agent_max_rounds`. A2 `shareCurrentChat` linked branch upserts (PUT, 404→create+re-link) before `POST /{id}/share`, aborts on failure (banner, no token); unlinked branch byte-identical. A3 `savePending` + `finally` re-run. A4 `saveFailedSticky` + one 5 s `setTimeout` retry on network/5xx only, 4xx never retries.
- `uv run pytest --cov=app --cov-report=term-missing` → **4609 passed, 1 warning, EXIT=0**, `TOTAL 99%`, `app/schemas.py 100%` (>90%).
- `uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov` → **4 passed, EXIT=0** (isolation).
- Regression green & **unedited** (`git status` clean): `test_chat_save_payload_limits.py` 2, `test_chat_history.py` 5, `test_share_chat.py` 4. Extra blast-radius sweep (share/save/persist/segments surface, all EXIT=0): `test_save_share_ux`, `test_stale_saved_chats`, `test_history_pagination`, `test_history_copy`, `test_bottom_chat_actions`, `test_reasoning_bubble`, `test_tool_budget_telemetry`, `test_verbatim_show_text`, `test_smoke`. No leaked `saved_chats` rows (`save-long-%` = 0).
- `uv run ruff check .` → All checks passed; `uv run pyright` → **0 errors**.
- **Completion criteria:** caps ✓ · incident payload 22/33/51 → 201/200 anonymously + 201 tools/451 segments/101 suggestions → 422 naming the offender, row unchanged ✓ · share-saves-first + abort + unlinked byte-identical ✓ · overlap re-run + sticky note + one-shot retry ✓ · phase E2E in isolation + 3 regression suites unedited ✓ · full suite / coverage / lint / types ✓ · `git diff --stat` = `app/schemas.py`, `frontend/assets/app.js`, 3 test files, `README.md` + new E2E & phase files (no `app/api/chats.py`, no migration, no new dependency) ✓ · commit + `todo/`→`complete/` move left to the harness (rules forbid git index writes).
- **Docs:** full README review (all 2 258 lines) + machine cross-check — all 85 settings documented in the Configuration reference, all 34 `.env.example` vars documented, documented defaults match `app/config.py`. Fixed 2 inconsistencies: (1) the 117-char prose line task 04 left when reflowing the Saved-chats pager paragraph; (2) the phase-189 sentence implying the 8 M total bounds the widened arrays — now states precisely that the total counts `text`/`thinking`/verbatim-block text only, and that the arrays are bounded by fixed item COUNT (matches `_require_within_total_budget`). AGENTS.md verified consistent — it enumerates no save contract or list cap (rule 5's phase-166 in-flight-turn clause untouched), so **no AGENTS.md change** per the phase mandate.
- **Notable/deviation:** the wider caps raise the *theoretical* anonymous-POST worst case (arrays are outside the 8 M char budget); left as designed (A1 fixes every other bound), documented honestly in README — realistic payloads stay ~10 MB, so the existing `client_max_body_size ≥ 16 MB` guidance still holds. No live-infra changes were made (data-repair of the two stale shared rows stays owner action).
- **Next pending phase:** none — `189_chat_save_long_turns` is the last entry in `.agents/phases/todo/`.
This commit is contained in:
2026-10-17 07:36:28 -04:00
parent f20d40e4fe
commit 140a343b92
36 changed files with 2642 additions and 65 deletions
+791
View File
@@ -0,0 +1,791 @@
"""Phase 189 E2E (Playwright): a LONG agent turn saves — and Share publishes it.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_save_long_turns.py -v --no-cov
WHY THESE TESTS EXIST (owner incident 2026-10-16, read this before editing)
--------------------------------------------------------------------------
Shared chat ``0101d2ca-b24c-4d13-9f65-c0837e993c50`` (row
``8b345ab5-aab3-4412-9642-0f869b3cec4d``) shows ONLY the user's question
although the LLM answered it completely. The app log for that row carries the
whole sequence:
* the per-turn line ``thinking=xhigh thinking_chars=23937 tool_calls=33
rounds=34 cap_hit=0 total_ms=400173`` + ``POST /api/chat 200 stream=1`` —
the client consumed the FULL 400 s stream, so the ``done`` frame landed;
* the done frame carried **22 sources** (the cited tier is the agent's READ
docs since phase 119 — ``merge_cited_docs`` — not the retrieval top-N, and
production runs ``BOR_AGENT_MAX_ROUNDS=50``), 33 tool calls and one
thinking entry per contiguous thinking run;
* immediately after it: ``PUT /api/chats/8b345ab5… 422 Unprocessable
Entity`` — the done-time auto-save was REJECTED at the phase-83 list caps
sized for the round-cap-10 era (``sources`` ≤ 20, ``tools`` ≤ 50,
``segments`` ≤ 50);
* the phase-55 A2 quiet contract swallowed the failure (a one-line
``#send-status`` note, no banner) and NOTHING retried, so the row kept its
pre-turn state — ``created_at == updated_at``, ``n_msgs = 1``, a
single-write user-only row;
* ``POST …/share`` then minted its token on that stale row (84 other
single-write user-only rows exist, 2 of them shared).
The four tests below pin the owner-visible contract of the phase's four
fixes, through the real UI and the real wire:
* **A1 (task 01) — the caps follow the real configuration.** The incident
payload shape (22 ``sources`` / 33 ``tools`` / 51 ``segments``) is
**201** on ``POST /api/chats`` and **200** on ``PUT /api/chats/<id>``,
anonymously, and the row round-trips all three lists (pre-phase: both
**422** — the incident). ``tests/integration/test_chats_api.py`` pins the
over-cap variants at the new boundaries (201/201/451/101 → 422).
* **A3 (task 03) — a save point dropped by the in-flight guard re-runs.**
A real mock turn's done-time auto-save lands even when the send-time
auto-save is still parked at the server: the row ends with BOTH messages
(pre-phase: the dropped save never came back and the row held 1).
* **A4 (task 03) — a failed save stays visible.** The one-line
"Couldn't save automatically — will try on the next message." note is on
screen after the failed auto-saves (no error banner — the A2 shape is
unchanged) and the next successful save clears it.
* **A2 (task 02) — Share saves first.** With the auto-saves failing, the
Share click UPSERTS the current conversation before minting the token
(pinned in the request log: an in-cap ``PUT`` immediately precedes the
``POST …/share``), and the shared page renders the BRAIN answer — not just
the question (pre-phase: exactly that, the frozen truncated snapshot).
Wire-shape notes for whoever edits the interception helpers: a Playwright
route handler that ``time.sleep``s blocks the sync dispatcher thread, so the
test thread cannot act during the hold — the delay-based "click while the
auto-save is parked" shape therefore races the parked (stale) write against
the share's own write for the row. Scenario 4 makes the row stale the
deterministic way instead (an injected **422** on the auto-saves — exactly
the incident's rejection, and one phase 189 A4 deliberately never retries),
which leaves no competing write in flight; scenario 3 keeps the hold and pins
the overlap PROPERLY by timestamping the answer's arrival inside the page (a
``MutationObserver``) and comparing it with the release time of the parked
request — so it cannot quietly degrade into a non-overlapping turn that passes
for the wrong reason. Scenario 3 holds the first WRITE (the ``POST`` create —
a fresh conversation's send-time save point creates the row; a ``PUT`` only
exists once a row is linked) and scenario 4's stale-row turn is a second turn
on a linked row, where the save points are ``PUT``s.
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
suites, so every test here uses a DISTINCTIVE question text (its auto-title
is therefore unique), never asserts on absolute row counts, and deletes the
rows it creates in a ``finally`` (admin cookie). The KB tables are truncated
+ re-seeded the house way (deterministic mock embeddings) ONCE per module —
these tests only need a GROUNDED mock answer, never a corpus change.
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from collections.abc import Callable
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, BrowserContext, Page, Route, 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
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The phase-55 A2 quiet note (unchanged by phase 189 — only its LIFETIME
#: is longer: it now stays until the next successful save).
SAVE_NOTE = "Couldn't save automatically — will try on the next message."
#: The saved-chat WRITE surface as a URL pattern: the collection (the
#: ``POST /api/chats`` create) and one row (the ``PUT /api/chats/<id>``
#: update). The share mint (``POST /api/chats/<id>/share``) and the chat
#: SSE stream (``/api/chat``) deliberately do NOT match.
CHAT_WRITE_URL = re.compile(r"https?://[^/]+/api/chats(/[0-9a-f-]+)?$")
#: The share mint, observed (never held) so the request LOG proves the
#: upsert-before-token ordering of phase 189 A2.
CHAT_SHARE_URL = re.compile(r"https?://[^/]+/api/chats/[0-9a-f-]+/share$")
#: Long enough that the mock turn (≈ 1–2 s on this corpus) settles while the
#: first auto-save is still parked; short enough to keep the suite quick.
HOLD_S = 6.0
#: The page-side watch that stamps WHEN the answer text first reached the
#: DOM. Needed because the route handler's hold blocks the sync dispatcher
#: (see the module docstring): without a page-side clock the overlap
#: scenario could not prove the done-time save point arrived mid-upsert.
_WATCH_ANSWER_JS = """
(marker) => {
window.__borAnswerAt = null;
const check = () => {
const bubbles = document.querySelectorAll('.msg.brain .bubble');
if (!bubbles.length) return;
const last = bubbles[bubbles.length - 1];
if ((last.textContent || '').includes(marker)
&& window.__borAnswerAt === null) {
window.__borAnswerAt = Date.now();
}
};
new MutationObserver(check).observe(document.body, {
childList: true, subtree: true, characterData: true,
});
check();
}
"""
# ---------------------------------------------------------------------------
# DB / app plumbing (the house pattern from test_chat_history.py)
# ---------------------------------------------------------------------------
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 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"]
@pytest.fixture(scope="module")
def kb(mock_llm: int) -> None:
"""Truncate the KB (and the query log — deterministic mock answers) and
re-import the fixtures ONCE for the module: every scenario here only
needs a grounded (non-deflected) mock answer. ``saved_chats`` is
deliberately NOT touched — rows persist across suites and each test
cleans up after itself."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
_run_in_thread(_import_fixtures(mock_llm))
# ---------------------------------------------------------------------------
# Admin-side helpers (the API is the proof — auto-saves are SILENT, A2)
# ---------------------------------------------------------------------------
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _find_row(
app_url: str, cookies: dict[str, str], title: str
) -> dict[str, Any] | None:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return next((c for c in r.json()["chats"] if c["title"] == title), None)
def _row(app_url: str, cookies: dict[str, str], chat_id: str) -> dict[str, Any]:
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200, r.text
return r.json()
def _wait_row_messages(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int,
page: Page,
timeout_s: float = 20.0,
) -> dict[str, Any]:
"""Poll the admin list until the row with this conversation's auto-title
holds AT LEAST ``messages`` messages (the upsert is fire-and-forget from
the UI's point of view, and the parked one lands when the hold ends).
The poll pumps the browser (``page.wait_for_timeout``) rather than
``time.sleep``: the sync Playwright dispatcher only runs while the test
thread is inside a Playwright call, so a sleeping poll starves a pending
route interception — the re-run upsert would sit unanswered and the wait
would time out on a browser that is actually fine."""
deadline = time.monotonic() + timeout_s
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = _find_row(app_url, cookies, title)
if last is not None and last["message_count"] >= messages:
return last
page.wait_for_timeout(200)
raise AssertionError(f"no auto-saved row for {title!r} with {messages} msgs (last: {last!r})")
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _cleanup(
app_url: str, cookies: dict[str, str], chat_id: str | None, title: str
) -> None:
"""Delete the row this test created — by id when the test got that far,
by its unique auto-title otherwise (a FAILING test must not leak a row
into the shared e2e database)."""
if chat_id is None:
leaked = _find_row(app_url, cookies, title)
if leaked is None:
return
chat_id = str(leaked["id"])
_delete_chat(app_url, cookies, chat_id)
def _ask(page: Page, question: str) -> float:
"""Send one turn and wait until the answer has fully landed (the
``done`` event restored the Send button). Returns the seconds the turn
took — the calibration the ``HOLD_S`` comment cites."""
started = time.monotonic()
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
return time.monotonic() - started
def _watch_answer(page: Page) -> None:
"""Install the page-side clock (see ``_WATCH_ANSWER_JS``) BEFORE the send
whose turn it stamps."""
page.evaluate(_WATCH_ANSWER_JS, MOCK_ANSWER_MARKER)
def _answer_at(page: Page) -> float | None:
"""The wall-clock ms at which the answer text first reached the DOM
(``None`` when the watch never fired)."""
value = page.evaluate("() => window.__borAnswerAt")
return None if value is None else float(value)
def _msg_count(post_data: str | None) -> int:
"""How many messages a save body carries — the request-level view of
what the auto-save was trying to write."""
try:
messages = json.loads(post_data or "{}").get("messages")
except json.JSONDecodeError:
return -1
return len(messages) if isinstance(messages, list) else -1
def _hold_first_chat_write(page: Page, hold_s: float = HOLD_S) -> list[dict[str, Any]]:
"""Hold the FIRST write to the saved-chat surface for ``hold_s`` seconds
before letting it reach the server (the archive-upload suite's pattern),
and log every write that passes through.
Each entry is ``{kind, messages, started_ms, released_ms}`` with ``kind``
in ``create``/``update``/``mint``; ``started_ms``/``released_ms`` are wall
clock ms (comparable with the page's ``Date.now()``), which is what lets
scenario 3 prove the done-time save point landed while the create was
still parked."""
log: list[dict[str, Any]] = []
state = {"held": False, "seq": 0}
def handle(route: Route) -> None:
req = route.request
if req.method not in ("POST", "PUT"):
route.continue_()
return
started = time.time() * 1000
entry = {
"seq": state["seq"],
"kind": "create" if req.method == "POST" else "update",
"messages": _msg_count(req.post_data),
"outcome": "held",
"started_ms": started,
"released_ms": started,
}
state["seq"] += 1
if not state["held"]:
state["held"] = True
time.sleep(hold_s) # the parked auto-save — nothing reaches the server yet
entry["released_ms"] = time.time() * 1000
log.append(entry)
route.continue_()
def handle_mint(route: Route) -> None:
log.append(
{
"seq": state["seq"],
"kind": "mint",
"messages": -1,
"outcome": "pass",
"started_ms": time.time() * 1000,
"released_ms": time.time() * 1000,
}
)
state["seq"] += 1
route.continue_()
page.route(CHAT_WRITE_URL, handle)
page.route(CHAT_SHARE_URL, handle_mint)
return log
def _reject_chat_writes(
page: Page,
) -> tuple[list[dict[str, Any]], Callable[[bool], None]]:
"""Answer every write to the saved-chat surface with an injected **422**
(the incident's own rejection) and log every write that passes through.
Returns the log and the arming switch: ``set_armed(False)`` lets the
writes through (the Share's own upsert must reach the server) while the
log keeps recording, so the ordering assertion covers the share too.
Two deliberate choices:
* a REJECTION, not a delay — a held request keeps a STALE body in flight
that then races the share's own write for the row (see the module
docstring), while a rejected save leaves the row exactly as the
incident left it (behind the conversation) with no competing write;
* a **422**, not a 5xx — phase 189 A4 retries a TRANSIENT failure only,
so nothing re-saves behind the test's back and the ONLY thing that can
bring the row up to date is the Share's own upsert (A2). The shared
page is therefore a clean witness for A2, and the request log is the
ordering proof."""
log: list[dict[str, Any]] = []
state = {"seq": 0, "armed": True}
def handle(route: Route) -> None:
req = route.request
if req.method not in ("POST", "PUT"):
route.continue_()
return
entry = {
"seq": state["seq"],
"kind": "create" if req.method == "POST" else "update",
"messages": _msg_count(req.post_data),
"outcome": "rejected" if state["armed"] else "pass",
}
state["seq"] += 1
log.append(entry)
if state["armed"]:
route.fulfill(
status=422,
content_type="application/json",
body='{"detail":[{"loc":["body"],"msg":"injected by phase 189 e2e"}]}',
)
return
route.continue_()
def handle_mint(route: Route) -> None:
log.append(
{
"seq": state["seq"],
"kind": "mint",
"messages": -1,
"outcome": "pass",
}
)
state["seq"] += 1
route.continue_()
def set_armed(value: bool) -> None:
state["armed"] = value
page.route(CHAT_WRITE_URL, handle)
page.route(CHAT_SHARE_URL, handle_mint)
return log, set_armed
def _grant_clipboard(page: Page, app_url: str) -> None:
"""Grant the async-clipboard permissions (``http://127.0.0.1`` is a
secure context, but headless Chromium still needs the grant before
``writeText`` resolves — otherwise the inline-link fallback fires)."""
page.context.grant_permissions(
["clipboard-read", "clipboard-write"], origin=app_url
)
def _click_share(page: Page) -> None:
"""Press the chat page's Share pill and wait for its outcome on the live
region — the clipboard line or the inline-field fallback, whichever the
context supports (the phase-51 contract)."""
page.locator("#share-chat-btn").click()
if page.evaluate("() => !!navigator.clipboard"):
expect(page.locator("#send-status")).to_have_text(
"Share link copied.", timeout=20_000
)
else:
expect(page.locator("#send-status")).to_have_text(
"Share link ready — copy it from the field.", timeout=20_000
)
# ---------------------------------------------------------------------------
# The incident payload: the done frame of a 34-round / 33-tool turn
# ---------------------------------------------------------------------------
def _incident_source(i: int) -> dict[str, str]:
"""A real ``SourceRef`` at realistic column values (``source`` ≤ 120,
``path`` ≤ 1000, ``title`` ≤ 500 — the phase-83 mirrors of the
``documents`` columns)."""
return {
"source": "Homelab",
"path": f"notes/deploy/kubernetes/component-{i:02d}.md",
"title": f"Component {i:02d} — cluster setup, upgrades and rollback notes",
}
def _incident_tool(i: int) -> dict[str, Any]:
"""A real ``ToolCall`` as the UI's ``toolAcc`` keeps it (the read the
agent ran in round ``i``)."""
return {
"name": "read",
"argument": f"Homelab/notes/deploy/kubernetes/component-{i:02d}.md",
"truncated": False,
"chars_shown": 4_200 + i,
"chars_total": 4_200 + i,
"offset": None,
"limit": None,
}
def _incident_brain() -> dict[str, Any]:
"""The incident turn's brain record: 22 ``sources`` (the READ docs —
``merge_cited_docs``), 33 ``tools``, 51 ``segments`` (the 33 tool entries
+ 18 thinking entries, one per contiguous thinking run of the 34-round
``xhigh`` turn)."""
tools = [_incident_tool(i) for i in range(33)]
segments: list[dict[str, Any]] = []
for i, tool in enumerate(tools):
if i < 18: # a thinking run opens the first 18 rounds
segments.append({"kind": "thinking", "text": f"round {i} scratchpad"})
segments.append({"kind": "tool", **tool})
return {
"who": "brain",
"text": "Everything I found, across all 22 documents I read.",
"sources": [_incident_source(i) for i in range(22)],
"related": [_incident_source(i) for i in range(2)],
"deflected": False,
"suggestions": [f"What about component {i}?" for i in range(5)],
"thinking": " ".join(f"round {i}" for i in range(18)),
"tools": tools,
"segments": segments,
}
def _assert_incident_lists(messages: list[dict[str, Any]]) -> None:
"""All three lists survived the round trip intact — nothing silently
truncated on the way in, and the segment chronology is preserved."""
brain = messages[1]
assert len(brain["sources"]) == 22
assert len(brain["related"]) == 2
assert len(brain["tools"]) == 33
assert len(brain["segments"]) == 51
assert [s["path"] for s in brain["sources"]] == [
_incident_source(i)["path"] for i in range(22)
]
assert [t["argument"] for t in brain["tools"]] == [
_incident_tool(i)["argument"] for i in range(33)
]
kinds = [s["kind"] for s in brain["segments"]]
assert kinds[:3] == ["thinking", "tool", "thinking"]
assert kinds[-1] == "tool"
assert kinds.count("thinking") == 18 and kinds.count("tool") == 33
# ---------------------------------------------------------------------------
# 1. A1 at the wire: the incident payload saves (201 / 200), anonymously
# ---------------------------------------------------------------------------
def test_incident_payload_shape_saves_at_the_wire(
page: Page, app_url: str, db_ready: None
) -> None:
"""THE incident regression at the network layer: the done-time auto-save
of a long turn — 22 cited READ docs, 33 tool calls, 51 timeline segments
— is accepted anonymously (``POST`` **201**, the re-save ``PUT``
**200**) and the row round-trips all three lists. Pre-phase-189 both
calls were **422** at the round-cap-10 list caps, the quiet contract
swallowed the failure and the row kept its pre-turn (question-only)
state."""
page.set_default_timeout(30_000)
# Settled ANONYMOUS state — the write surface is public, and the
# rejection happened on an anonymous browser save (the audit vector).
page.goto(app_url + "/")
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
question = "Audit my whole homelab and tell me what needs updating (phase 189 wire)"
body = {
"messages": [{"who": "user", "text": question}, _incident_brain()],
}
created = page.request.post(app_url + "/api/chats", data=body)
assert created.status == 201, (
f"the incident shape must save, not 422: {created.status} {created.text()}"
)
payload = created.json()
chat_id = str(payload["id"])
assert payload["message_count"] == 2
_assert_incident_lists(payload["messages"])
try:
# The done-time auto-save's actual path: the re-save upsert of the
# linked row (the incident's 422 rode exactly this request).
updated = page.request.put(app_url + f"/api/chats/{chat_id}", data=body)
assert updated.status == 200, (
f"the incident shape must re-save, not 422: {updated.status} {updated.text()}"
)
_assert_incident_lists(updated.json()["messages"])
# The row itself, through the admin read (the browser's own origin).
login(page, app_url, next="/")
cookies = _admin_cookies(page)
got = _row(app_url, cookies, chat_id)
assert got["message_count"] == 2
_assert_incident_lists(got["messages"])
finally:
login(page, app_url, next="/")
_delete_chat(app_url, _admin_cookies(page), chat_id)
# ---------------------------------------------------------------------------
# 2. A real mock turn's done-time auto-save lands (row: 2 messages)
# ---------------------------------------------------------------------------
def test_done_time_auto_save_lands_for_a_mock_turn(
page: Page, app_url: str, kb: None, db_ready: None
) -> None:
"""The happy path through the real UI: one grounded mock turn, no
interception at all. The conversation auto-saves at the save points and
the row ends with BOTH messages, the brain record holding the answer the
bubble showed (the baseline every other scenario here deviates from)."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (save-long-baseline)"
turn_s = _ask(page, q)
cookies = _admin_cookies(page)
created: str | None = None
try:
row = _wait_row_messages(app_url, cookies, _auto_title(q), 2, page)
created = str(row["id"])
assert row["message_count"] == 2, "the send + done save points both landed"
messages = _row(app_url, cookies, created)["messages"]
assert [m["who"] for m in messages] == ["user", "brain"]
assert messages[0]["text"] == q
assert MOCK_ANSWER_MARKER in messages[1]["text"], (
"the saved brain record holds the answer the bubble showed"
)
assert q[:40] in messages[1]["text"], (
"the mock answer echoes the question — the saved text is THAT turn"
)
# The calibration HOLD_S cites: the mock turn settles long before the
# hold ends, which is what makes scenario 3's overlap deterministic.
assert turn_s < HOLD_S - 2.0, (
f"the mock turn took {turn_s:.2f}s — HOLD_S ({HOLD_S}s) no longer "
"clears it, so scenario 3's overlap would not be deterministic"
)
finally:
_cleanup(app_url, cookies, created, _auto_title(q))
# ---------------------------------------------------------------------------
# 3. A3: the done-time save dropped by the in-flight guard RE-RUNS
# ---------------------------------------------------------------------------
def test_dropped_done_time_save_reruns_after_the_inflight_upsert_settles(
page: Page, app_url: str, kb: None, db_ready: None
) -> None:
"""The owner-visible half of evidence 6: the send-time auto-save is
parked at the server (held in the route), the mock turn settles inside
that window, and the done-time save point it produces is refused by the
one-upsert-at-a-time guard. Pre-phase-189 that save point VANISHED
("the next save point retries" — which never came) and the row stayed
question-only; phase 189 A3 marks it PENDING and the parked upsert's
``finally`` re-runs it once it settles, so the row ends with BOTH
messages."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
_watch_answer(page)
log = _hold_first_chat_write(page)
q = "How is my Kubernetes cluster set up? (save-long-overlap)"
_ask(page, q) # the answer lands while the create is still parked
cookies = _admin_cookies(page)
created: str | None = None
try:
row = _wait_row_messages(app_url, cookies, _auto_title(q), 2, page)
created = str(row["id"])
# The owner-visible outcome: the conversation saved COMPLETE.
assert row["message_count"] == 2, (
"the dropped done-time save must re-run once the parked upsert settles"
)
messages = _row(app_url, cookies, created)["messages"]
assert [m["who"] for m in messages] == ["user", "brain"]
assert MOCK_ANSWER_MARKER in messages[1]["text"]
# The mechanism, from the request log: ONE create carrying only the
# question (the send-time save point), then exactly ONE update
# carrying both messages (the pending re-run). Pre-phase-189 the
# update never happened at all.
assert [e["kind"] for e in log] == ["create", "update"], (
f"expected the parked create then the single re-run, got {log}"
)
assert log[0]["messages"] == 1, "the send-time save held only the question"
assert log[1]["messages"] == 2, "the re-run carried the brain record"
# And the overlap is REAL, not a turn that happened to be slow: the
# answer reached the DOM while the create was still parked, so the
# done-time save point ran into the in-flight guard (a page-side
# clock — the test thread cannot observe anything during the hold).
answer_at = _answer_at(page)
parked = log[0]
assert answer_at is not None, "the page-side answer watch never fired"
assert parked["started_ms"] < answer_at < parked["released_ms"], (
f"the answer reached the DOM at {answer_at}, outside the window the "
f"send-time save was parked "
f"[{parked['started_ms']}, {parked['released_ms']}] — the save points "
"did not overlap, so this test would not pin the re-run"
)
finally:
_cleanup(app_url, cookies, created, _auto_title(q))
# ---------------------------------------------------------------------------
# 4. A2 (+A4 visible): Share saves the CURRENT conversation before the token
# ---------------------------------------------------------------------------
def test_share_saves_first_publishes_the_brain_message(
page: Page,
browser: Browser,
app_url: str,
kb: None,
db_ready: None,
) -> None:
"""The incident's second half, through the real UI: the auto-saves do not
land (an injected 422 — EXACTLY the incident's rejection), so the row
stays behind the conversation, and the owner clicks Share. Pre-phase-189
the Share minted its token on whatever the row last held, so the shared
page showed ONLY the question; phase 189 A2 upserts the current
conversation first (an in-cap ``PUT`` in the request log immediately
before the ``POST …/share``) and the shared page carries the brain
answer. A4 rides along visibly: the failed auto-saves leave the one-line
note on screen (still no error banner, the phase-55 A2 shape)."""
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
# Turn 1 saves cleanly, so the conversation is LINKED to a row (the
# branch the incident rode: a linked Share, token-only pre-phase).
q1 = "How is my Kubernetes cluster set up? (save-long-share-1)"
_ask(page, q1)
cookies = _admin_cookies(page)
row = _wait_row_messages(app_url, cookies, _auto_title(q1), 2, page)
chat_id = row["id"]
anon_ctx: BrowserContext | None = None
try:
# The two auto-save points of turn 2 (send, brain-done) are refused:
# the row stays at turn 1's state — the incident's stale row.
log, set_armed = _reject_chat_writes(page)
q2 = "How is my Kubernetes cluster set up? (save-long-share-2)"
_ask(page, q2)
rejected = [e for e in log if e["outcome"] == "rejected"]
assert len(rejected) == 2, f"both turn-2 save points must be refused: {log}"
assert [e["messages"] for e in rejected] == [3, 4], (
f"the send point held 3 messages, the done point all 4: {log}"
)
# The row is BEHIND the conversation — the incident's precondition.
assert _row(app_url, cookies, chat_id)["message_count"] == 2
# A4: the failure is NOT silent any more — the one-line note is on
# screen (and still no error banner, the phase-55 A2 shape).
expect(page.locator("#send-status")).to_contain_text(SAVE_NOTE)
expect(page.locator("#kb-banner")).to_be_hidden()
# A2: the Share click saves FIRST, then mints. (The injection comes
# off for the share itself — a share whose upsert fails ABORTS, and
# that branch is marker-pinned in tests/unit/test_save_chat_ui.py.)
set_armed(False)
_grant_clipboard(page, app_url)
_click_share(page)
upserts = [e for e in log if e["kind"] == "update" and e["outcome"] == "pass"]
mints = [e for e in log if e["kind"] == "mint"]
assert len(mints) == 1, f"exactly one token mint: {log}"
assert upserts, (
"the linked Share must upsert the current conversation before minting"
)
assert upserts[-1]["seq"] < mints[-1]["seq"], (
f"the share's upsert must precede the mint: {log}"
)
assert upserts[-1]["messages"] == 4, (
"the share upsert carried the WHOLE conversation, answer included"
)
# The row caught up (2 turns = 4 messages) and carries the token.
row = _wait_row_messages(app_url, cookies, _auto_title(q1), 4, page)
assert row.get("share_url"), "the mint produced the share link"
share_url = str(row["share_url"])
# The shared page: the FULL conversation — the brain answer of turn 2
# included (pre-phase-189: one question and nothing else).
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q1))
expect(anon.locator(".msg.user .bubble")).to_have_count(2)
bubbles = anon.locator(".msg.brain .bubble")
expect(bubbles).to_have_count(2)
expect(bubbles.nth(1)).to_contain_text(MOCK_ANSWER_MARKER, timeout=20_000)
# The shared answer is turn 2's — the incident lost exactly this.
expect(bubbles.nth(1)).to_contain_text("(save-long-share-2)")
# Zero chips restored for a turn that read nothing (the phase-119
# shape the shared page keeps).
expect(anon.locator("a.source-chip")).to_have_count(0)
finally:
if anon_ctx is not None:
anon_ctx.close()
_delete_chat(app_url, cookies, chat_id)
+183 -10
View File
@@ -1859,16 +1859,18 @@ def test_post_rejects_more_than_500_messages_and_stores_nothing(
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
def test_post_rejects_sources_over_20_and_stores_nothing(
def test_post_rejects_sources_over_200_and_stores_nothing(
client: TestClient, admin_client: TestClient
) -> None:
"""One brain message carrying a 21-item ``sources`` list (all valid
``SourceRef`` shapes) 422 (``ChatMessage.sources`` ``max_items=20``
— top-N docs + agent reads) and nothing lands."""
"""One brain message carrying a 201-item ``sources`` list (all valid
``SourceRef`` shapes) 422 (``ChatMessage.sources`` ``max_length=200``
— phase 189 A1: the cited tier is the agent's READ docs, so the cap
follows ``agent_max_rounds`` (production 50) × calls-per-round, not
``top_n_docs``) and nothing lands."""
baseline = len(admin_client.get("/api/chats").json()["chats"])
sources = [
{"source": f"src{i}", "path": f"docs{i}.md", "title": f"Doc {i}"}
for i in range(21)
for i in range(201)
]
r = client.post(
@@ -1885,6 +1887,176 @@ def test_post_rejects_sources_over_20_and_stores_nothing(
assert len(admin_client.get("/api/chats").json()["chats"]) == baseline
# ---------- the phase-189 incident payload (owner incident 2026-10-16) ----------
#
# The shared chat 0101d2ca… (row 8b345ab5…) shows ONLY the question: the
# turn's done frame carried 22 sources (the cited tier is the agent's READ
# docs since phase 119 — merge_cited_docs), 33 tool calls and one thinking
# entry per contiguous thinking run, and the done-time auto-save 422'd at
# the pre-phase caps (sources 20 / tools 50 / segments 50). The quiet
# contract swallowed the failure, nothing retried, and Share minted its
# token on the stale row. These pins are the wire-level regression: the
# same shape saves.
def _incident_source(i: int) -> dict[str, Any]:
return {"source": "Homelab", "path": f"notes/doc{i}.md", "title": f"Doc {i}"}
def _incident_brain() -> dict[str, Any]:
"""The incident turn's brain record: 22 ``sources``, 33 ``tools``, 51
``segments`` (the 33 tool entries + 18 thinking entries — one per
contiguous thinking run of a 34-round ``xhigh`` turn)."""
tools: list[dict[str, Any]] = [
{
"name": "read",
"argument": f"Homelab/notes/doc{i}.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
for i in range(33)
]
segments: list[dict[str, Any]] = []
for i, tool in enumerate(tools):
if i < 18: # one thinking entry opens the first 18 runs
segments.append({"kind": "thinking", "text": f"thinking run {i}"})
segments.append({"kind": "tool", **tool})
assert len([s for s in segments if s["kind"] == "thinking"]) == 18
return {
"who": "brain",
"text": "The whole investigation, across every document I read.",
"sources": [_incident_source(i) for i in range(22)],
"related": [_incident_source(i) for i in range(2)],
"deflected": False,
"suggestions": [f"Tell me more about doc {i}" for i in range(5)],
"thinking": "".join(f"run {i} " for i in range(18)),
"tools": tools,
"segments": segments,
}
def _assert_incident_lists(messages: list[dict[str, Any]]) -> None:
"""The brain record of a saved conversation carries all three lists
intact (order included) — nothing silently truncated on the way in."""
brain = messages[1]
assert len(brain["sources"]) == 22
assert len(brain["related"]) == 2
assert len(brain["tools"]) == 33
assert len(brain["segments"]) == 51
assert brain["sources"] == [_incident_source(i) for i in range(22)]
assert brain["segments"] == _incident_brain()["segments"]
assert brain["tools"] == _incident_brain()["tools"]
def test_post_accepts_the_incident_payload_shape(
client: TestClient, admin_client: TestClient, db: Session
) -> None:
"""THE incident regression at the wire: the 22-source / 33-tool /
51-segment done-time auto-save is **201** (pre-phase-189: 422 into the
quiet failure) and the row round-trips all three lists byte-for-byte
— anonymous, exactly as the browser's auto-save posts it."""
r = client.post(
"/api/chats",
json={
"messages": [
_user("Audit my whole homelab and tell me what needs updating"),
_incident_brain(),
]
},
)
assert r.status_code == 201, f"expected 201, got {r.status_code}: {r.text}"
body = r.json()
_assert_incident_lists(body["messages"])
raw = db.execute(
text("SELECT messages FROM saved_chats WHERE id = :id"),
{"id": body["id"]},
).scalar_one()
_assert_incident_lists(raw)
got = admin_client.get(f"/api/chats/{body['id']}")
assert got.status_code == 200
_assert_incident_lists(got.json()["messages"])
def test_put_accepts_the_incident_payload_shape(
client: TestClient, admin_client: TestClient
) -> None:
"""The SAME shape through the re-Save upsert (the path the done-time
auto-save actually rides) is **200** and the row's stored messages
bump to the new array — the pre-turn snapshot is replaced, not
rejected."""
created = client.post(
"/api/chats",
json={"messages": [_user("Audit my whole homelab and tell me what needs updating")]},
).json()
assert created["message_count"] == 1
r = client.put(
f"/api/chats/{created['id']}",
json={
"messages": [
_user("Audit my whole homelab and tell me what needs updating"),
_incident_brain(),
]
},
)
assert r.status_code == 200, f"expected 200, got {r.status_code}: {r.text}"
_assert_incident_lists(r.json()["messages"])
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["message_count"] == 2 # bumped to the new array
_assert_incident_lists(got.json()["messages"])
def test_put_rejects_over_cap_lists_and_leaves_row_unchanged(
client: TestClient, admin_client: TestClient
) -> None:
"""The over-cap variants at the NEW boundaries (201 ``tools``, 451
``segments``, 101 ``suggestions``) each 422 with the framework's
standard body (phase 83 A2: no custom error copy, no route change) and
the row keeps its stored payload byte-for-byte — the rejected body
never reaches the JSONB."""
created = client.post(
"/api/chats", json={"messages": _simple_conversation()}
).json()
kept = _expect(_simple_conversation())
over_tools = _incident_brain()
over_tools["tools"] = over_tools["tools"] + [over_tools["tools"][0]] * 168 # 201
assert len(over_tools["tools"]) == 201
over_segments = _incident_brain()
over_segments["segments"] = over_segments["segments"] + [
{"kind": "thinking", "text": "one too many"}
] * 400 # 451
assert len(over_segments["segments"]) == 451
over_suggestions = _incident_brain()
over_suggestions["suggestions"] = [f"chip {i}" for i in range(101)]
for bad, needle in (
(over_tools, "tools"),
(over_segments, "segments"),
(over_suggestions, "suggestions"),
):
r = client.put(
f"/api/chats/{created['id']}",
json={"messages": [_user("hi"), bad]},
)
assert r.status_code == 422, f"{needle}: expected 422, got {r.status_code}: {r.text}"
assert needle in json.dumps(r.json()), f"{needle}: 422 must name the offender"
got = admin_client.get(f"/api/chats/{created['id']}")
assert got.status_code == 200
assert got.json()["messages"] == kept
assert got.json()["message_count"] == 2
def test_put_rejects_oversized_message_and_leaves_row_unchanged(
client: TestClient, admin_client: TestClient
) -> None:
@@ -2137,10 +2309,11 @@ def test_shared_serve_includes_segments(admin_client: TestClient) -> None:
assert "segments" not in body["messages"][0]
def test_segments_over_50_entries_rejected(admin_client: TestClient) -> None:
"""``max_length=50`` (the phase-83 voice, the ``tools`` cap): 51
entries 422 at the boundary — far past any realistic turn (the
round cap is 10 → ≤ 20 entries)."""
def test_segments_over_450_entries_rejected(admin_client: TestClient) -> None:
"""``max_length=450`` (the phase-189 sizing — ≤ 200 tool entries + ≤
~50 thinking runs + margin, replaces the phase-83 "round cap is 10 →
≤ 20 entries"): 451 entries 422 at the boundary while the
incident-shaped 51 (above) saves."""
r = admin_client.post(
"/api/chats",
json={
@@ -2148,7 +2321,7 @@ def test_segments_over_50_entries_rejected(admin_client: TestClient) -> None:
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "x"}] * 51,
"segments": [{"kind": "thinking", "text": "x"}] * 451,
}
]
},
+318 -7
View File
@@ -41,6 +41,25 @@ so a silent regression is caught without a browser:
slide-down + fade entry via ``.toast.is-visible``, and the
reduced-motion override (transform dropped for BOTH states, the
opacity fade kept);
* phase 189 task 02 (A2, the owner incident 2026-10-16): the LINKED
``shareCurrentChat`` branch UPSERTS the current conversation (the
``persistConversation`` PUT + the 404→recreate fallback + the 201
re-link) BEFORE minting the share token, and an upsert failure ABORTS
the share (the neutral banner, no token, no toast) — a Share can never
publish a row the conversation has outgrown. The UNLINKED branch
(``POST /api/chats`` with ``share: true``) is byte-identical;
* phase 189 task 03 (A3/A4, the same incident): the ``persisting``
double-fire guard no longer DROPS an overlapping save point — it sets
``savePending`` and the in-flight upsert's ``finally`` re-runs
``persistConversation()`` ONCE after it settles (``persisting = false``
first, the flag cleared before the re-run — never concurrent, never
stacking). A failed save's one-line note is STICKY (``saveFailedSticky``,
cleared by the next SUCCESS along with ``#send-status``), and a
TRANSIENT failure (the network ``catch`` arm or a 5xx) schedules ONE
retry after 5 s behind the one-shot ``retryScheduled`` guard — a 4xx
NEVER retries (a 422 is deterministic; task 01's cap realign is the
remedy). The A2 contract's SHAPE is unchanged: the same note text,
still no error banner, still silent on success.
* phase 55 task 05 (the action row, owner-locked A5): a single
``<div class="chat-actions">`` in index.html wraps BOTH pills as its
element children (DOM order New chat → Share), replacing the two
@@ -203,7 +222,12 @@ def test_persist_conversation_is_headless_and_quiet() -> None:
one-line #send-status note — on BOTH failure paths (non-ok HTTP and
network) — with the "next save point retries" promise. The
module-level `persisting` flag is the double-fire guard (released
in the finally — never stuck)."""
in the finally — never stuck).
Phase 189 (A3) turns the DROPPED call into a PENDING one: the guard
arm still returns without issuing a second request (the upsert stays
one-at-a-time), it only remembers the save point for the ``finally``
re-run pinned by test_persisting_overlap_sets_pending_and_reruns."""
js = _js()
body = _fn(js, "persistConversation")
assert "showErrorBanner" not in body, "A2: a failed auto-save never raises a banner"
@@ -223,14 +247,177 @@ def test_persist_conversation_is_headless_and_quiet() -> None:
assert re.search(r"^let persisting = false", js, re.M), (
"the persisting flag is module scope (save points can overlap)"
)
assert "if (persisting) return;" in body, "an in-flight upsert skips the second call"
assert "persisting = true;" in body
# Phase 189 (A3): the guard arm still SKIPS the call (no second
# upsert, no second request) — it only records the save point for the
# finally re-run pinned by test_persisting_overlap_sets_pending_and_reruns.
guard = body.find("if (persisting) {")
guard_return = body.find("return;", guard)
assert -1 < guard < guard_return < body.find("persisting = true;"), (
"an in-flight upsert skips the second call"
)
assert body.count("persisting = true;") == 1, "one upsert in flight, ever"
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "persisting = false" in body[finally_idx:], (
"the flag is released in the finally — never stuck"
)
def test_persisting_overlap_sets_pending_and_reruns() -> None:
"""Phase 189 (A3, the owner incident 2026-10-16): a save point that
arrives while an upsert is in flight is PENDING, not dropped. The
guard arm sets ``savePending`` and returns; a fresh run CONSUMES the
flag at the top of its body (so a re-run never schedules a second
one); the ``finally`` re-runs ``persistConversation()`` once, AFTER
``persisting = false`` (the re-run is a fresh serialized call —
never concurrent with the upsert it trails) and clears the flag
before re-entering. Because the payload is built at CALL time, the
re-run carries the LATEST ``conversation`` — the done-time save a
still-running send-time save used to swallow lands with the brain
record."""
js = _js()
body = _fn(js, "persistConversation")
# All three phase-189 flags live at module scope (state across the
# overlapping save points), beside the pre-existing `persisting`.
for flag in ("savePending", "saveFailedSticky", "retryScheduled"):
assert re.search(rf"^let {flag} = false;", js, re.M), (
f"{flag} is module scope like `persisting`"
)
noop = body.find("if (!conversation.length) return;")
first_fetch = body.find("await fetch(")
guard = body.find("if (persisting) {")
guard_return = body.find("return;", guard)
assert -1 < noop < guard < first_fetch, (
"the overlap guard still sits before any request"
)
set_pending = body.find("savePending = true;", guard)
assert guard < set_pending < guard_return, (
"the in-flight arm marks the save point PENDING instead of dropping it"
)
# A fresh run consumes the flag — before the payload is built, so the
# re-run is THIS run and cannot schedule a second re-run.
start = body.find("persisting = true;")
consume = body.find("savePending = false;", start)
assert -1 < start < consume < first_fetch, (
"the run consumes the pending flag at the top of its body"
)
payload = body.find("JSON.stringify({ messages: conversation })")
assert consume < payload, (
"the payload is built at call time — the re-run reads the LATEST conversation"
)
# The finally: release FIRST, then re-run once (never concurrent).
finally_idx = body.rfind("finally")
assert finally_idx > first_fetch, "the release lives in the finally"
tail = body[finally_idx:]
release = tail.find("persisting = false")
recheck = tail.find("if (savePending)", release)
rerun = tail.find("void persistConversation();", recheck)
assert -1 < release < recheck < rerun, (
"the re-run starts only after the in-flight upsert fully settled"
)
clear = tail.find("savePending = false;", recheck)
assert recheck < clear < rerun, "the flag is cleared before re-entering — one re-run, not two"
assert tail.count("void persistConversation();") == 1, "exactly one re-run site"
def test_failed_save_note_is_sticky_until_success() -> None:
"""Phase 189 (A4): a failed auto-save's note does not vanish with the
next paint — both failure arms set ``saveFailedSticky`` after the
EXACT phase-55 note text (the A2 shape is untouched: the same
one-line #send-status note, still NO error banner, still no success
text), and the SUCCESS path clears the flag and resets
``#send-status``. The clear sits on the success path only — after the
201 link and the stale-banner clear, never in a failure arm."""
js = _js()
body = _fn(js, "persistConversation")
note = "Couldn't save automatically — will try on the next message."
assert body.count(note) == 2, "the A2 note text is unchanged, on both failure paths"
assert "showErrorBanner" not in body, "A2 unchanged: a failed auto-save never raises a banner"
assert "Conversation saved." not in body, "A2 unchanged: success stays silent"
# Non-ok arm: note, then the sticky flag.
notok = body.find("if (!res.ok)")
first_note = body.find(note)
first_sticky = body.find("saveFailedSticky = true;", first_note)
assert -1 < notok < first_note < first_sticky, (
"the non-ok arm notes AND marks the note sticky"
)
# Network arm (the catch): the same note, the same flag.
catch_idx = body.find("} catch {")
second_note = body.find(note, catch_idx)
second_sticky = body.find("saveFailedSticky = true;", second_note)
assert catch_idx < second_note < second_sticky, (
"the catch arm notes AND marks the note sticky"
)
assert body.count("saveFailedSticky = true;") == 2, "both — and only both — failure arms"
# Success: the flag clears and the status line resets, on the success
# path only (after the 201 link + the stale-banner clear).
link = body.find("res.status === 201")
hide = body.find("staleBanner.hidden = true")
clear_guard = body.find("if (saveFailedSticky)")
assert -1 < link < hide < clear_guard < catch_idx, (
"the stuck note clears on the success path, never on failure"
)
block = body[clear_guard : body.find("}", clear_guard)]
assert "saveFailedSticky = false;" in block, "the flag resets with the note"
assert 'sendStatus.textContent = "";' in block, "the status line returns to empty"
def test_transient_failure_retries_once_after_5s() -> None:
"""Phase 189 (A4): a TRANSIENT failure — the network ``catch`` arm or
a 5xx status — schedules ONE retry after 5 000 ms. The one-shot
``retryScheduled`` guard is set BEFORE the ``setTimeout`` and reset
INSIDE the callback (overlapping failures never stack retries), and
the retry is a plain ``persistConversation()`` — the LATEST
conversation through the same guards (the pending re-run included)."""
js = _js()
body = _fn(js, "persistConversation")
assert body.count("setTimeout(") == 2, "one retry site per transient arm — no more"
for arm_start in (body.find("if (!res.ok)"), body.find("} catch {")):
arm_end = body.find("} finally {", arm_start)
assert -1 < arm_start < arm_end
timeout = body.find("setTimeout(", arm_start)
assert arm_start < timeout < arm_end, "the arm schedules the retry"
guard = body.find("retryScheduled = true;", arm_start)
assert arm_start < guard < timeout, (
"the one-shot guard is set BEFORE the setTimeout"
)
delay = body.find(", 5000);", timeout)
assert timeout < delay < arm_end, "the retry waits 5 000 ms"
callback = body[timeout:delay]
assert "retryScheduled = false;" in callback, "the guard resets INSIDE the callback"
assert "void persistConversation();" in callback, "the retry is a plain auto-save"
# The 5xx condition is the ONLY status gate on the non-ok arm.
notok_arm = body[body.find("if (!res.ok)") : body.find("} catch {")]
assert "res.status >= 500" in notok_arm
def test_4xx_never_retries() -> None:
"""Phase 189 (A4): a 4xx is DETERMINISTIC — a 422 (the incident: the
22-source payload over the old 20-cap) would 422 again, and task 01's
cap realign is the remedy. The non-ok arm's only scheduling site sits
behind ``res.status >= 500``, so every other non-ok status (404 aside
— the recreate fallback — 409, 422, 400) notes and returns with no
timer; the arm still marks the note sticky (it stays visible)."""
js = _js()
body = _fn(js, "persistConversation")
notok = body.find("if (!res.ok)")
catch_idx = body.find("} catch {")
arm = body[notok:catch_idx]
assert arm.count("setTimeout(") == 1, "the non-ok arm has exactly ONE scheduling site"
gate = arm.find("if (res.status >= 500 && !retryScheduled) {")
timeout = arm.find("setTimeout(")
assert -1 < gate < timeout, "the only retry is gated on a 5xx status"
# Nothing else in the arm keys off a 4xx, and nothing after the gate's
# block runs before the return.
for forbidden in ("status === 4", "status >= 4", "status < 500", "retryScheduled"):
assert forbidden not in arm[timeout + len("setTimeout(") :].replace(
"retryScheduled = false;", ""
), f"no extra scheduling branch keyed on {forbidden!r}"
assert arm.find("saveFailedSticky = true;") < arm.find("return;"), (
"a 4xx still leaves the note visible — it just never retries"
)
assert body.count("setTimeout(") == 2, "the catch arm is the only other scheduling site"
def test_auto_save_wired_to_the_save_points() -> None:
"""The headless helper is referenced from the save points: the user
send (runTurn's ``!reask`` block — after the localStorage
@@ -489,7 +676,14 @@ def test_share_current_chat_save_then_share_branch() -> None:
again" — the write surface is public, phase 55 task 01, so a 403
is no longer a sign-in problem for a guest); network → the
reachable? banner. The double-click guard releases in the finally —
never stale."""
never stale.
Phase 189 (task 02) moves the ORDERING pins to the create-with-share
payload: the linked branch now ALSO holds a POST (its 404→recreate
fallback), so "the first /api/chats POST is the unlinked arm" no
longer reads — ``share: true`` is what identifies that arm (the
upsert-before-mint ordering itself is pinned by
test_share_linked_branch_upserts_before_the_share_mint)."""
js = _js()
body = _fn(js, "shareCurrentChat")
# No-op first: nothing to share → live-region line, no fetch.
@@ -506,7 +700,7 @@ def test_share_current_chat_save_then_share_branch() -> None:
assert 'JSON.stringify({ messages: conversation, share: true })' in body, (
"the create-with-share payload — the server sets the token in the same commit"
)
post_idx = body.find('fetch("/api/chats", {')
post_idx = body.find('JSON.stringify({ messages: conversation, share: true })')
assert -1 < share_fetch < post_idx, "the linked branch precedes the unlinked fallback"
created_idx = body.find("currentChatId = String(created.id)", post_idx)
assert created_idx != -1, "one action saved AND shared: the conversation links to the row"
@@ -529,8 +723,10 @@ def test_share_current_chat_save_then_share_branch() -> None:
# is public); the network banner keeps its own line.
assert 'showErrorBanner("Couldn\'t share the conversation — is the app reachable?")' in body
neutral = "Couldn't share the conversation — try again."
assert body.count(neutral) == 2, (
"both the linked and the unlinked branch carry the neutral non-ok banner"
assert body.count(neutral) == 3, (
"three non-ok arms carry the SAME neutral banner: the phase-189 "
"upsert abort, the linked share mint, and the unlinked create "
"(no sign-in wording on any of them)"
)
assert "signed in" not in body, "no sign-in wording left in the share handler (task 03)"
# The double-click guard releases on EVERY outcome.
@@ -571,6 +767,121 @@ def test_share_current_chat_save_then_share_branch() -> None:
assert "document.createRange()" in sel and "selectNodeContents(el)" in sel
def test_share_linked_branch_upserts_before_the_share_mint() -> None:
"""Phase 189 (task 02, A2): the LINKED branch of ``shareCurrentChat``
upserts the CURRENT conversation BEFORE minting the token — the owner
incident (2026-10-16: a row holding only the question because the
done-time auto-save 422'd into the phase-55 quiet failure; the Share
minted on that stale row and published a truncated snapshot) is the
why. The upsert carries ``persistConversation``'s semantics: the
``{ messages: conversation }`` payload + JSON headers, the PUT to
/api/chats/<id>, the 404→recreate fallback (unlink, then POST
/api/chats with the SAME body/headers) and the 201 re-link. The
share mint is the LAST request of the branch (string-ordering pin —
no browser)."""
js = _js()
body = _fn(js, "shareCurrentChat")
branch = body.find("if (currentChatId) {")
else_idx = body.find("} else {")
assert -1 < branch < else_idx
arm = body[branch:else_idx]
put = arm.find('`/api/chats/${currentChatId}`')
mint = arm.find('`/api/chats/${currentChatId}/share`')
assert -1 < put < mint, "the upsert PUT precedes the share mint (save-first)"
# The persistConversation-shaped payload + headers, byte-identical to
# the auto-save's (ONE upsert shape, not two).
persist = _fn(js, "persistConversation")
assert arm.find('const body = JSON.stringify({ messages: conversation });') < put
assert 'const body = JSON.stringify({ messages: conversation });' in persist, (
"the share upsert reuses the auto-save's payload shape"
)
assert 'const headers = { "Content-Type": "application/json" };' in arm
assert 'method: "PUT"' in arm, "the linked branch PUTs the linked row"
# The 404→recreate fallback (the same stale-link rule as the auto-save).
notfound = arm.find("res.status === 404")
assert put < notfound < mint, "a PUT 404 (row deleted on History) is handled"
assert "currentChatId = null" in arm[notfound:mint], "the stale link is dropped"
fallback = arm.find('fetch("/api/chats", { method: "POST", headers, body })')
assert notfound < fallback < mint, (
"the 404 retries as a create with the SAME body/headers"
)
relink = arm.find("currentChatId = String((await res.json()).id)")
assert fallback < relink < mint, (
"the 201 re-links the conversation to the recreated row"
)
# The mint rides AFTER the upsert settled — and it is the branch's
# LAST request (nothing publishes before the row catches up).
assert arm.count("await fetch(") == 3, "PUT, the recreate POST, then the mint"
last_fetch = arm[arm.rfind("await fetch(") :].split("\n")[0]
assert "/share`" in last_fetch, "the share mint is the linked branch's LAST fetch"
# The docstring says WHY (the incident), not just what.
doc = js[: js.find("async function shareCurrentChat()")]
doc = doc[doc.rfind("/*"):]
assert "UPSERTS the current conversation BEFORE minting the token" in doc
assert "truncated snapshot" in doc, "the incident rationale is in the docstring"
def test_share_aborts_on_upsert_failure() -> None:
"""Phase 189 (task 02, A2): an upsert FAILURE aborts the share. The
failure arm that sits BETWEEN the upsert and the mint raises the
EXACT neutral banner text and ``return``s — no token minted, no
toast, no link handed to the owner (the mint line and every success
path come after it). The double-click guard still releases in the
finally, and a network throw still lands on the reachable? banner
(the outer catch, unchanged)."""
js = _js()
body = _fn(js, "shareCurrentChat")
branch = body.find("if (currentChatId) {")
arm = body[branch : body.find("} else {")]
mint = arm.find('`/api/chats/${currentChatId}/share`')
notok = arm.find("if (!res.ok)")
banner = arm.find(
'showErrorBanner("Couldn\'t share the conversation — try again.")', notok
)
assert -1 < notok < banner < mint, (
"the non-ok upsert raises the neutral banner BEFORE the mint line"
)
ret = arm.find("return;", banner)
assert notok < ret < mint, "the abort returns — the mint is never reached"
abort = arm[notok:ret]
assert "showToast" not in abort, "the abort toasts nothing (the toast stays success-only)"
assert "share_url" not in abort, "no link is read or handed out on the abort"
assert "copyShareLinkWithFallback" not in abort, "no copy on the abort"
assert "showToast" not in arm[:mint], "no toast anywhere before the mint"
# Both non-ok arms of the linked branch carry the SAME neutral copy:
# the upsert abort and the mint's own failure.
assert arm.count('showErrorBanner("Couldn\'t share the conversation — try again.")') == 2, (
"the upsert abort + the mint's non-ok arm — the neutral copy, twice in the branch"
)
finally_idx = body.rfind("finally")
assert finally_idx != -1 and "shareBtn.disabled = false" in body[finally_idx:], (
"the button releases even on the abort — never stale"
)
def test_share_unlinked_branch_unchanged() -> None:
"""Phase 189 (task 02): the UNLINKED branch is byte-identical — one
``POST /api/chats`` with ``{ messages: conversation, share: true }``
(the server sets the 128-bit token in the same commit — one action
saves AND shares, owner-locked) and the 201 re-link. It carries no
PUT and no separate share mint: the save-first upsert belongs to the
linked arm only (its POST already saves the CURRENT conversation)."""
js = _js()
body = _fn(js, "shareCurrentChat")
else_idx = body.find("} else {")
copy_idx = body.find("copyShareLinkWithFallback(absoluteShareUrl(shareUrl))")
assert -1 < else_idx < copy_idx
arm = body[else_idx:copy_idx]
assert 'fetch("/api/chats", {' in arm
assert 'method: "POST"' in arm
assert 'JSON.stringify({ messages: conversation, share: true })' in arm
assert "currentChatId = String(created.id)" in arm, "the 201 re-links (saved AND shared)"
assert 'shareUrl = created.share_url' in arm, "the token comes from the same commit"
assert 'method: "PUT"' not in arm, "the unlinked arm never PUTs — unchanged"
assert "/share" not in arm, "the unlinked arm never mints a separate token"
assert "share: true" not in body[:else_idx], "share: true stays out of the linked arm"
def test_share_button_has_no_reveal_gate() -> None:
"""Phase 55 task 03: the pill is VISIBLE TO EVERY VISITOR — app.js
queries #share-chat-btn and binds the click to shareCurrentChat, but
+169 -10
View File
@@ -21,6 +21,15 @@ boundaries as the saved-chat mirror, and the 422 ``detail`` SHAPE a cap
rejection produces is pinned as the server-side half of the phase-164
serializer contract (a LIST OF OBJECTS — the live ``[object Object]``
report).
Phase 189 (task 01, A1 — the owner incident of 2026-10-16) moves the five
LIST caps from the round-cap-10 era to the real agent configuration
(``sources``/``related``/``tools`` 200, ``suggestions`` 100, ``segments``
450): the cited tier is the agent's READ docs (``merge_cited_docs``,
phase 119), so its size follows ``BOR_AGENT_MAX_ROUNDS`` (production 50,
the validator unbounded above), and a 22-source done frame used to 422
its own auto-save. The character caps are UNCHANGED; the incident payload
(22 sources / 33 tools / 40 segments) joins the round-trip pins.
"""
from __future__ import annotations
@@ -54,10 +63,26 @@ from app.schemas import (
#: 600 000 ≈ a full-cap answer at ``BOR_MAX_OUTPUT_TOKENS=131_072``
#: (≈ 524 300 chars at ~4 chars/token) with margin.
TEXT_CAP = 600_000
SOURCES_CAP = 20
SUGGESTIONS_CAP = 50
#: Phase 189 (A1, owner incident 2026-10-16): the list caps move from the
#: round-cap-10 era to the real configuration. The cited tier is the
#: agent's READ DOCS since phase 119 (``merge_cited_docs``), so its size
#: follows ``agent_max_rounds`` (production ``BOR_AGENT_MAX_ROUNDS=50``,
#: the validator unbounded above) — 200 is 4× that cap. A 22-source done
#: frame used to 422 its own done-time auto-save (the incident).
SOURCES_CAP = 200
#: The related-doc tier — the same ``SourceRef`` family, the same cap
#: (``related_max_docs`` is owner-tunable with no upper bound).
RELATED_CAP = 200
#: 20× the ``suggested_docs`` default of 5 (also tunable unbounded above).
SUGGESTIONS_CAP = 100
CHIP_CAP = 200
TOOLS_CAP = 50
#: 4× the production round cap — a round is ONE model request and
#: one-or-more tool calls per round are legal.
TOOLS_CAP = 200
#: ≤ the ``tools`` cap (200 tool entries) + ≤ ~50 thinking runs (one per
#: round at the production cap) + margin — the phase-158 "50 matches the
#: tools cap" sizing is retired with the round-cap-10 assumption.
SEGMENTS_CAP = 450
SOURCE_CAP = 120 # documents.source String(120)
PATH_CAP = 1000 # documents.path String(1000)
TITLE_CAP = 500 # documents.title String(500)
@@ -94,6 +119,26 @@ def _tool_call() -> dict:
}
def _thinking_segment(text: str = "a thinking run") -> dict:
"""One contiguous thinking run (the phase-158 timeline entry)."""
return {"kind": "thinking", "text": text}
def _tool_segment(index: int) -> dict:
"""One tool entry in the timeline — the exact ToolCall mirror, the
phase-146 scope ABSENT (a plain read, the omission serializer)."""
return {
"kind": "tool",
"name": "read",
"argument": f"Homelab/doc{index}.md",
"truncated": False,
"chars_shown": None,
"chars_total": None,
"offset": None,
"limit": None,
}
def _user_message(text: str = "How did I install k3s?") -> ChatMessage:
return ChatMessage.model_validate({"who": "user", "text": text})
@@ -142,7 +187,9 @@ def test_chat_message_thinking_none_still_valid() -> None:
# ---------------------------------------------------------------------------
# ChatMessage.sources / .suggestions / .tools
# ChatMessage.sources / .related / .suggestions / .tools / .segments — the
# phase-189 list caps (the character caps above keep their phase-83/164
# numbers; these five follow the agent's real round configuration)
# ---------------------------------------------------------------------------
@@ -216,6 +263,56 @@ def test_chat_message_tools_one_over_cap_rejects() -> None:
_failed_loc(exc.value, "tools")
def test_chat_message_related_at_cap_validates() -> None:
"""Phase 189 (A1): the related-doc tier carries the SAME
``SourceRef``-family cap as ``sources`` (200) — ``related_max_docs``
is owner-tunable with no upper bound, so the tier must clear the same
worst case or a long turn 422s its own auto-save."""
msg = ChatMessage.model_validate(
{"who": "brain", "text": "ok", "related": [_source_ref() for _ in range(RELATED_CAP)]}
)
assert len(msg.related or []) == RELATED_CAP
assert RELATED_CAP == SOURCES_CAP # one SourceRef-family cap
def test_chat_message_related_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"related": [_source_ref() for _ in range(RELATED_CAP + 1)],
}
)
_failed_loc(exc.value, "related")
def test_chat_message_segments_at_cap_validates() -> None:
"""The list cap at the phase-189 number (450): the worst-case turn at
the production round cap (200 tool entries + ~50 thinking runs) fits
with margin."""
msg = ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [_thinking_segment() for _ in range(SEGMENTS_CAP)],
}
)
assert len(msg.segments or []) == SEGMENTS_CAP
def test_chat_message_segments_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
ChatMessage.model_validate(
{
"who": "brain",
"text": "ok",
"segments": [_thinking_segment() for _ in range(SEGMENTS_CAP + 1)],
}
)
_failed_loc(exc.value, "segments")
# ---------------------------------------------------------------------------
# ChatRequest.message (the 4,000-char question cap — phase 104, A3)
# ---------------------------------------------------------------------------
@@ -701,6 +798,66 @@ def test_realistic_bor_chat_v1_payload_round_trips() -> None:
assert dumped["share"] is False
def test_incident_long_turn_payload_round_trips() -> None:
"""Phase 189 (task 01, owner incident 2026-10-16) EXTENDS the
realistic-payload family with the shape that actually broke: one long
research turn's brain record carrying 22 ``sources`` (the cited tier
is the agent's READ docs since phase 119), 33 ``tools`` and 40
``segments`` (the 33 tool entries + one thinking entry per contiguous
thinking run — 7 runs). At the pre-phase caps (``sources`` 20,
``tools``/``segments`` 50) this record 422'd its own done-time
auto-save, the phase-55 A2 quiet contract swallowed the failure, and
the shared row kept ONLY the question. It now validates and
``model_dump()``s byte-identically — the omission rules untouched (the
tool entries carry no ``path`` key, so none comes back)."""
sources = [
{"source": "Homelab", "path": f"notes/doc{i}.md", "title": f"Doc {i}"}
for i in range(22)
]
# 33 tool entries interleaved with 7 thinking runs (buildSegments'
# chronological shape: one thinking entry per contiguous run).
segments: list[dict] = []
tools: list[dict] = []
for run in range(7):
segments.append(_thinking_segment(f"thinking run {run}"))
for _ in range(5 if run < 3 else 4): # 3*5 + 4*4 = 31 + 2 = 33
segments.append(_tool_segment(len(tools)))
tools.append(_tool_call())
while len(tools) < 33: # the last run's remaining calls
segments.append(_tool_segment(len(tools)))
tools.append(_tool_call())
assert len(sources) == 22 and len(tools) == 33 and len(segments) == 40
assert sum(1 for s in segments if s["kind"] == "thinking") == 7
record = {
"who": "brain",
"text": "The whole investigation, across 22 documents.",
"sources": sources,
"related": sources[:2],
"deflected": False,
"suggestions": [f"Show doc {i}" for i in range(5)],
"thinking": "".join(f"run {r} " for r in range(7)),
"tools": tools,
"segments": segments,
"stopped": None,
"failed": None,
"error": None,
"budget_exhausted": None,
}
saved = SavedChatCreate.model_validate(
{"title": "long research turn", "messages": [record]}
)
dumped = saved.model_dump()["messages"][0]
assert dumped["sources"] == sources
assert dumped["tools"] == tools
assert dumped["segments"] == segments
# Every entry round-trips through the discriminated union unchanged,
# and the scoped-grep key stays ABSENT (never null) on the plain reads.
assert all("path" not in s for s in dumped["segments"] if s["kind"] == "tool")
assert ChatMessage.model_validate(record).model_dump() == dumped
def test_realistic_payload_round_trips_through_update_model() -> None:
"""Same record re-Saved through ``SavedChatUpdate`` (the re-Save
upsert surface) — identical stored shape."""
@@ -1064,8 +1221,9 @@ def test_segments_tool_entry_mirrors_toolcall_shape() -> None:
def test_segments_caps_mirrored_not_weakened() -> None:
"""The ToolCall caps bind on the segment (mirrored, not
weakened): ``name`` ≤ 100, ``argument`` ≤ 2 000, ``path`` ≤ 2 000,
``offset``/``limit`` ≥ 1, the list ≤ 50 (the phase-83 voice — the
``tools`` cap)."""
``offset``/``limit`` ≥ 1, and the list cap is the phase-189
``SEGMENTS_CAP`` (450) — NOT the ``tools`` cap any more (the two
separated when the round-cap-10 sizing was retired)."""
bad: list[dict] = [
{"kind": "tool", "name": "n" * (NAME_CAP + 1), "argument": None},
{"kind": "tool", "name": "grep", "argument": "a" * (ARGUMENT_CAP + 1)},
@@ -1084,7 +1242,7 @@ def test_segments_caps_mirrored_not_weakened() -> None:
{
"who": "brain",
"text": "ok",
"segments": [{"kind": "thinking", "text": "x"}] * (TOOLS_CAP + 1),
"segments": [{"kind": "thinking", "text": "x"}] * (SEGMENTS_CAP + 1),
}
)
_failed_loc(exc.value, "segments")
@@ -1205,9 +1363,10 @@ def test_saved_record_answer_text_round_trips_and_stays_optional() -> None:
def test_saved_record_answer_text_list_is_capped() -> None:
"""The list cap (the phase-83 SEC-05 style, the ``tools`` /
``segments`` number): 51 blocks reject, 50 fit — far past the
``show_text_max_blocks`` default of 5."""
"""The list cap (the phase-83 SEC-05 style): 51 blocks reject, 50 fit
— far past the ``show_text_max_blocks`` default of 5. The cap stays 50
through phase 189 (it follows ``show_text_max_blocks``, not the round
count)."""
blocks = [_verbatim_block(locator=f"Homelab/a{i}.md") for i in range(51)]
with pytest.raises(ValidationError):
ChatMessage.model_validate(