chore(agent): track .agent/ planning tree in git
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s

Remove the blanket .agent/ gitignore so the phase roadmap, user
stories, reports, and PLAN.md are versioned with the code. Only
runtime artifacts (.agent/phase-sessions/, .agent/pipeline.log)
remain ignored. Update AGENTS.md git protocol rule to match.
This commit is contained in:
2026-09-01 10:18:22 -04:00
parent 5fa620fde5
commit 4971e2859d
818 changed files with 23964 additions and 4 deletions
@@ -0,0 +1,48 @@
# Phase 50 — Save & View Chat History
**Source:** `TODO.md` L5 — "Need a way to save and view chat history in a new page, then return to that history with a click"
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-08-29)
**Context:** A10 (revised, phase 16) keeps the public **chat** API stateless, and phase 14 locked conversation persistence to browser-local `localStorage` (`bor.chat.v1`). This phase records an **owner-locked extension** (AGENTS.md rule 3, owner permission 2026-08-29): conversations the owner explicitly **Saves** are stored in a new Postgres `saved_chats` table — `/api/chat` itself stays stateless, and nothing is stored about a conversation that was not saved. The admin gate is `app/core/auth.require_admin` (phase 16, the `steering` router's pattern). The localStorage record shape (`{who, text, sources?, deflected?, suggestions?, thinking?, tools?, stopped?}`) is the stored `messages` payload, so a saved chat restores pixel-identical through the existing `renderStoredMessage` path. The new page follows the phase-34 shared-header contract (every page carries the identical nav block; admin-only links ship hidden and `header.js` reveals them) and AGENTS.md rule 5 (a **full-width table** — no skinny wasted-space list).
## Objective
The owner can Save the current conversation, see every saved chat on a new **History** page (full-width table), click one to return to the chat with that conversation loaded, and delete a saved chat.
## Dependencies
- `48_stop_generation` / `49_retry_answer` (todo, sequential) — no shared-file conflicts beyond `app.js`; ordering keeps the chat UI stable while the save/load plumbing lands.
- `14_chat_persistence` (complete) — the record shape + the `renderStoredMessage` restore a saved chat reuses.
- `16_admin_auth` + `34_consistent_navbar` (complete) — the `require_admin` gate + the one-bar nav contract.
- **Owner permission (2026-08-29):** the A10 extension — a new `saved_chats` table for explicitly saved conversations (see Context).
## Tasks
1. `01_saved_chat_model.md` — the `SavedChat` model + migration `0008_saved_chats` (+ migration test).
2. `02_chats_api.md` — admin-only CRUD under `/api/chats` + schemas + integration tests.
3. `03_save_chat_ui.md` — the chat page: Save button, `?chat=<id>` load, upsert semantics, live-region feedback, absent for anonymous.
4. `04_history_page.md` — `history.html` + `history.js` (full-width table, Open + two-step Delete), the `#nav-history` admin-only nav link on every page, the cache-busting page registration, CSS.
5. `05_e2e_chat_history.md` — the story Playwright suite + regressions + commit.
## Testing & Quality
- Integration: `tests/integration/test_chats_api.py` (the CRUD contract: 403 anonymous, create auto-title, list order, get, put replacement, delete 404/204, message-shape validation).
- Integration: `tests/integration/test_migration_0008.py` (the house migration-test pattern from `test_migration_0007.py`).
- Coverage: **>90%** on `app/`.
- E2E (mandatory, A16): `tests/e2e/test_chat_history.py`, run in isolation.
## Completion Criteria
- [ ] Admin: Save on the chat page stores the conversation (auto-title = first question, 120-char cap); re-Save on the same conversation updates the same row; New chat unlinks.
- [ ] `/history.html` (admin) lists saved chats in a **full-width** table (Title, Messages, Updated, Actions); a row's title opens `/?chat=<id>` and the chat renders the stored conversation (sources, thinking, stopped notes, deflection chips — pixel-identical to the local restore); a subsequent Save updates that row.
- [ ] Delete removes the row (inline two-step confirm, no `window.confirm`); an unknown id 404s; `GET /api/chats` + the served `/history.html` carry the cache-busting contract (no-cache + `?v=` rewrite).
- [ ] Anonymous: no Save button, no History nav link, `/api/chats*` → 403, `/history.html` shows the gated state without fetching `/api/chats`.
- [ ] `uv run pytest` green; coverage TOTAL >90%.
- [ ] `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` green in isolation (DB up).
- [ ] Regression E2E suites green in isolation: `test_chat_persistence.py`, `test_nav_consistency.py`, `test_shared_header.py`, `test_admin_auth.py`, `test_cache_busting.py`.
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
## Locked decisions
- **Owner-locked extension (2026-08-29, recorded per AGENTS.md rule 3):** `saved_chats` in Postgres stores **only** conversations the owner explicitly saves; `/api/chat` stays stateless; phase 14's local persistence is unchanged (the localStorage session keeps working exactly as before — saving is an additional, explicit action).
- **Owner-locked (2026-08-29):** Save/History is **admin-only** (no account system — anonymous rows would be unfindable); absent-not-hidden for anonymous (phase 16); auto-title, no rename UI in v1 (the schema still accepts an optional `title`); re-Save = upsert of the same row; `?chat=<id>` replaces the local conversation and links it; inline two-step delete confirm (no `window.confirm`).
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
## Commit
```bash
git add -A .agent/ app/ alembic/versions/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): save and view chat history — admin-only saved_chats, History page, open-a-chat return"
```
@@ -0,0 +1,26 @@
# Task 01 — The `SavedChat` model + migration
**Phase:** `50_chat_history` · **Source:** `TODO.md:5` — "Need a way to save and view chat history in a new page, then return to that history with a click"
**Story:** n/a (TODO-derived)
## Objective
A `saved_chats` table — one row per explicitly saved conversation, the messages stored as the exact localStorage record shape — with migration `0008`.
## Work
1. `app/models.py` — a new `SavedChat` model (the module docstring's data-model list gains the entry, in the house style of the phase-15/30/35 entries):
- `id: Mapped[uuid.UUID]` — PK, `default=uuid.uuid4`
- `title: Mapped[str]` — `String(500)` — set by the API (auto-title; the column is plain so a future rename needs no migration)
- `messages: Mapped[list]` — `postgresql.JSONB` — `list[dict]` in the `bor.chat.v1` record shape (`{who: "user"|"brain", text, sources?, deflected?, suggestions?, thinking?, tools?, stopped?}` — raw text, never HTML, phase 14); the API always supplies a list, so no default is needed
- `created_at` / `updated_at: Mapped[datetime]` — `DateTime(timezone=True)`, `server_default=func.now()`; `updated_at` additionally carries `onupdate=func.now()`
- No share-related columns (phase 51 adds `share_token` in `0009` — this migration stays minimal).
2. `alembic/versions/0008_saved_chats.py` — `revision = "0008"`, `down_revision = "0007"` (verify the head first with `uv run alembic heads`): `op.create_table("saved_chats", …)` (UUID via `sqlalchemy.dialects.postgresql.UUID(as_uuid=True)`, JSONB via `sqlalchemy.dialects.postgresql.JSONB`) and the down `op.drop_table`.
3. `tests/integration/test_migration_0008.py` (new) — the house pattern from `tests/integration/test_migration_0007.py` (same DB/fixtures it uses; assert the table + columns exist at head, the `downgrade`/`upgrade` round-trip it exercises, and — where that pattern allows — the `updated_at` bump on row update).
4. Apply the migration to the dev/e2e DB: `uv run alembic upgrade head` (the E2E conftest's default `BOR_DATABASE_URL` is the same Postgres the dev server uses — it is already up).
## Testing & Quality
- Integration: the migration test above; full suite green.
- Coverage: **>90%** on `app/` (model-only — no new logic).
## Completion Criteria
- [ ] `uv run alembic upgrade head` applies cleanly (and the migration test's downgrade/upgrade round-trip passes).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,37 @@
# Task 02 — Admin-only `/api/chats` CRUD
**Phase:** `50_chat_history` · **Source:** `TODO.md:5` — "Need a way to save and view chat history in a new page, then return to that history with a click"
**Story:** n/a (TODO-derived)
## Objective
The saved-chat CRUD contract under `/api/chats`, admin-gated router-wide (the `steering` pattern), with a strict-enough message schema that a corrupted or HTML-shaped payload cannot poison a restored conversation.
## Work
1. `app/schemas.py` — new Pydantic models (the house style of the steering/git-source schemas):
- `ToolCall` — `name: str`, `argument: str | None` (the `tools` record shape, phase 37).
- `ChatMessage` — `who: Literal["user", "brain"]`, `text: str` (`min_length=1`), optional: `sources: list[SourceRef] | None`, `deflected: bool | None`, `suggestions: list[str] | None`, `thinking: str | None`, `tools: list[ToolCall] | None`, `stopped: bool | None` — `model_config = ConfigDict(extra="forbid")` so unknown keys (e.g. an HTML-shaped payload) are rejected at the boundary.
- `SavedChatCreate` — `title: str | None` (`max_length=500`), `messages: list[ChatMessage]` (`min_length=1`).
- `SavedChatUpdate` — `title: str | None`, `messages: list[ChatMessage]` (`min_length=1`).
- `SavedChatOut` — `id: uuid.UUID`, `title: str`, `created_at`, `updated_at`, `message_count: int`, `messages: list[ChatMessage]`.
- `SavedChatRow` — `id`, `title`, `updated_at`, `message_count` (the list page's row shape — no payloads in the list).
- `SavedChatList` — `chats: list[SavedChatRow]`.
2. `app/api/chats.py` (new) — `router = APIRouter(prefix="/chats", tags=["chats"], dependencies=[Depends(require_admin)])` (the phase-16 pattern; the module docstring documents the gate the way `steering.py` does, and records the A10 extension + owner permission 2026-08-29 — the "recorded revision, not a silent deviation" house style):
- `GET ""` → `SavedChatList` — rows ordered `updated_at desc, id desc` (latest activity first).
- `POST ""` (201) → `SavedChatOut` — the auto-title when `title` is absent/blank: the **first user message**'s text, whitespace-collapsed, truncated to 120 chars (owner-locked convention); a conversation with no user message (defensive — the UI cannot produce one) falls back to `"Chat <id-hex8>"`.
- `GET "/{chat_id}"` → `SavedChatOut` — 404 `{"detail": "unknown chat"}` on an unknown id.
- `PUT "/{chat_id}"` → `SavedChatOut` — full `messages` replacement; `title` replaced only when supplied (an absent `title` keeps the current one); 404 on unknown. (`updated_at` bumps via the model's `onupdate` — verify the ORM flush triggers it; if not, set `row.updated_at` explicitly in the route.)
- `DELETE "/{chat_id}"` → 204 — 404 on unknown.
- `db: Session = Depends(get_db)` throughout (the `noqa: B008` house style).
3. `app/main.py` — `app.include_router(chats_router, prefix="/api")` with the other API routers (before the static mount — the block's existing order).
4. `tests/integration/test_chats_api.py` (new) — the house pattern from `tests/integration/test_steering_api.py` (TestClient + DB fixtures as that file does it):
- anonymous: every route 403 (list/create/get/put/delete);
- admin (signed in via the same auth-helper pattern that file uses): create (auto-title from the first user message + the 120-char truncation; an explicit title honored; an empty `messages` list → 422; a `who: "alien"` → 422; an extra key on a message → 422), list order (a second, newer chat first), get (full payload round-trip — a brain record carrying `sources`/`thinking`/`tools`/`stopped` survives byte-identical), put (replacement + title-keep + title-set + the `updated_at` bump), delete (204 then get 404; delete unknown 404).
## Testing & Quality
- Integration: as above; full suite green.
- Coverage: **>90%** on `app/` (the new module fully covered — 404/422/403 branches included).
## Completion Criteria
- [ ] The contract holds end-to-end: 403 anonymous, 201 create, 200 list/get/put, 204 delete, 404 unknown, 422 malformed.
- [ ] A `bor.chat.v1`-shaped payload round-trips losslessly (the restore path is pixel-identical by construction).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,31 @@
# Task 03 — Save button + `?chat=<id>` load on the chat page
**Phase:** `50_chat_history` · **Source:** `TODO.md:5` — "Need a way to save and view chat history in a new page, then return to that history with a click"
**Story:** n/a (TODO-derived)
## Objective
The chat page gains the Save action (admin-only) and can boot into a saved chat through `/?chat=<id>` — opening a chat from History returns the owner to that exact conversation, with subsequent Saves updating the same row.
## Work
1. `frontend/index.html` — beside `#new-chat-btn` (the same `.chat-shell` block, the same ghost-pill family), `#save-chat-btn`: an inline save-glyph SVG + the label "Save" (`aria-label="Save chat"`); **ships `hidden`** (the ship-hidden / reveal-for-admin contract — `app.js` reveals it only when `isAdmin`; `hidden` is display:none, so anonymous visitors see no trace); a comment block documents the upsert + `?chat=<id>` contract (2026-08-29, `TODO.md` L5).
2. `frontend/assets/app.js`:
- **`currentChatId`** (module scope, `string | null`): set from `?chat=<id>` at boot, set to the created row's id on a fresh Save, cleared by `startNewChat`.
- **Boot-load** (inside the existing boot IIFE, after `fetchIsAdmin()`): when the URL's `?chat=` value is a valid uuid **and** `isAdmin`: `GET /api/chats/<id>` → on 200: `conversation = data.messages` (the records already match the local shape), render through the existing `renderStoredMessage` loop (sources / thinking / tools / stopped / deflection — pixel-identical to the local restore), `currentChatId = id`, then `saveConversation()` (the local session now mirrors the opened chat, so a plain refresh returns to it the phase-14 way) — and **skip** the localStorage restore for this load. On 404/network failure: `showErrorBanner("That saved chat isn't available — it may have been deleted.")` and fall through to the normal local restore. An invalid/absent param, or anonymous: the normal local restore runs (no fetch — the gate would 403).
- **`saveCurrentChat()`** — the `#save-chat-btn` handler: a no-op with the live-region line "Nothing to save yet." when `conversation` is empty. Otherwise: if `currentChatId` is set → `PUT /api/chats/<id>` with `{ messages: conversation }`; else `POST /api/chats` with `{ messages: conversation }` (the server auto-titles) → `currentChatId = created.id`. On 200/201: `sendStatus.textContent = "Conversation saved."` (the live region — the never-stale contract; status text only, no banner). On 404 from the PUT: unlink (`currentChatId = null`), retry as a create, and announce the outcome — the owner is never left with an unsaved conversation because of a stale link. On 403/5xx/network: `showErrorBanner` with an actionable line.
- **`startNewChat`** — add `currentChatId = null` to the existing reset (a new conversation is unlinked until saved again).
- **Reveal on boot:** next to `applyAuthState()`: `saveBtn.hidden = !isAdmin` (phase 16 absent-not-hidden for anonymous).
- **Header comment:** the save/load contract (2026-08-29, `TODO.md` L5).
3. `frontend/assets/styles.css` — `.save-chat-btn`: the exact visual family of `.new-chat-btn` (ghost pill, ≥44px, focus-visible, hover like `.nav-link`), so the two chat-shell actions read as a pair.
4. Frontend source pins (house pattern, extend `tests/unit/test_frontend_feedback.py` or a sibling): the `currentChatId` lifecycle (set on create/open, cleared on New chat, cleared on the 404-PUT fallback); the upsert branch (PUT when linked, POST when not, the 404→recreate fallback); the boot-load precedence (valid `?chat=` + admin replaces the local restore and mirrors it to storage; anonymous/invalid/404 → local restore); the `hidden` reveal gate.
- ASSUMPTION (owner-locked 2026-08-29): Save/History is admin-only; absent-not-hidden for anonymous; re-Save updates the same row; `?chat=<id>` replaces the local conversation and links it; auto-title, no rename UI in v1.
## Testing & Quality
- Unit: source pins as above; full suite green.
- Coverage: **>90%** on `app/` (unchanged — frontend-only task).
## Completion Criteria
- [ ] Admin sees the Save button; anonymous doesn't (display-none — no trace in the layout).
- [ ] Save → a row exists (integration-proven in task 02's API + E2E in task 05); re-Save updates it; New chat unlinks.
- [ ] `/?chat=<id>` restores the saved conversation pixel-identically; a bad/deleted id degrades to the local restore with a banner (E2E in task 05).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,30 @@
# Task 04 — The History page + nav link + cache-busting registration
**Phase:** `50_chat_history` · **Source:** `TODO.md:5` — "Need a way to save and view chat history in a new page, then return to that history with a click"
**Story:** n/a (TODO-derived)
## Objective
A new `/history.html` page (admin data) listing saved chats in a **full-width table** (AGENTS.md rule 5 — no skinny list), with Open (return to the chat) and Delete (inline two-step confirm) per row, the `#nav-history` admin-only nav link on every page (the phase-34 one-bar contract), and the page registered in the cache-busting middleware.
## Work
1. `frontend/history.html` (new) — the standard page scaffold exactly as `sources.html`/`tuning.html` do it: the head (charset, viewport, `assets/styles.css`), the skip link, the **identical** `.app-header` block copied from the other pages (brand, the `#nav-toggle` hamburger, the nav with the existing admin-only hidden links **plus** the new `<a href="/history.html" class="nav-link is-active" id="nav-history" hidden aria-current="page">History</a>` — placed after the Tuning link, the same ship-hidden contract, with a phase comment), the auth pair (the same Sign in/out markup with `?next=/history.html`), `<main id="main">` carrying the `#steering-panel` section (the shared panel ships on every page — phase 34) + `#steering-announcer`, then the page content: an `<h1>` "Saved chats" + sub-line, the **full-width table** skeleton (`<table class="history-table">` with a `<thead>`: Title | Messages | Updated | Actions (visually-hidden header text)), an empty-state row ("No saved chats yet — finish a conversation and press **Save** in the chat."), a `role="status"` live region for action feedback, and the app-footer (the version span, like the other pages). Scripts: `<script src="assets/brand.js"></script>` (classic, first) + `<script type="module" src="/assets/history.js"></script>`. **No-CDN rule** (AGENTS.md rule 6): every asset local.
2. `frontend/assets/history.js` (new) — a module in the `sources.js`/`tuning.js` house style:
- boot: `await initSharedHeader()` (header.js — whoami + nav reveal + the steering panel), then `fetchIsAdmin()`; **anonymous**: render the page's gated state following the `/sources.html` soft-gate pattern (the table area shows the gated/empty message; **no data fetch** — `/api/chats` is 403 for anonymous and must never be called; pin this in the E2E via the request log).
- admin: `GET /api/chats` → render the rows: **Title** as an `<a href="/?chat=<id>">` (Open is the title link — the "return to that history with a click" requirement), **Messages** (`message_count`), **Updated** (`updated_at` as a locale date+time, `title` attribute with the full ISO), **Actions**: **Delete** only (phase 51 adds the share column). Delete is an **inline two-step** (owner-locked: no `window.confirm` anywhere in the file): the first click turns the button into a small "Delete? [Yes] [No]" confirm pair (keyboard-accessible, focus moves to Yes); Yes → `DELETE /api/chats/<id>` → the row is removed + the live region `"Deleted "<title>"."`; a No or a failed request keeps the row (+ an error line on failure). A 0-row fetch shows the empty-state row.
- The table is **full-width**: `width: 100%` inside the standard `.container` (AGENTS.md rule 5 — no fixed skinny width).
3. **The nav link on every page** — add the identical `#nav-history` hidden link (after `#nav-tuning`, same markup + phase comment) to the nav block of `index.html`, `sources.html`, `git-sources.html`, `tuning.html`, `document.html`, `login.html` (the phase-34 one-bar contract — all pages), and in `frontend/assets/header.js` reveal it for admin exactly like `#nav-tuning` (the same `const navHistory = document.querySelector("#nav-history"); if (navHistory) navHistory.hidden = !admin;` block + comment). `history.html` itself carries the link with `is-active` + `aria-current="page"` (step 1).
4. `app/core/caching.py` — add `"/history.html"` to the `HTML_PAGES` tuple (with a phase-50 comment) so the new page gets the no-cache + `?v=` asset-rewrite contract like the others; update the docstring's page count wording if it names the five. `tests/unit/test_caching.py` — a pin that `HTML_PAGES` includes `/history.html` (extend the existing pins' style).
5. `frontend/assets/styles.css` — `.history-table`: the full-width table in the phase-08 dark-tech palette (the sources-table family — borders via the `--line` token, the header row on the surface-darker token, row hover, ≥4.5:1 ink colors, `th scope="col"` headers); the title link styled as an accent link (focus-visible); `.history-confirm` inline pair (Yes on the error-rose treatment, No ghost); the empty-state row (muted centered message); the ≤640px responsive behavior (actions wrap; the table keeps full width — the phase-07 responsive contract).
6. Frontend source pins (house pattern): the anonymous no-fetch gate; the two-step confirm (and a repo-wide `window.confirm` absence pin for `history.js`); the `/?chat=<id>` href shape; `id="nav-history"` present in **all seven** page files (a pin counting the occurrences across `frontend/*.html` = 7).
- ASSUMPTION (owner-locked 2026-08-29): the table columns are Title (the open link) / Messages / Updated / Actions; Delete is inline two-step; the nav link is admin-only, placed after Tuning.
## Testing & Quality
- Unit: source pins as above + the `caching.py` pin; full suite green.
- Coverage: **>90%** on `app/` (the `HTML_PAGES` change is trivially covered by the pin).
## Completion Criteria
- [ ] `/history.html` renders the shared header + the full-width table (admin: the rows; anonymous: the gated state, and **no** `/api/chats` request on the wire).
- [ ] The title link returns to `/?chat=<id>`; Delete's two-step removes the row (the API 404s afterwards).
- [ ] `#nav-history` is present (hidden) on all seven pages and revealed for admin only; `GET /history.html` carries `Cache-Control: no-cache` with `?v=`-tagged asset refs.
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,28 @@
# Task 05 — History E2E + regressions + commit
**Phase:** `50_chat_history` · **Source:** `TODO.md:5` — "Need a way to save and view chat history in a new page, then return to that history with a click"
**Story:** n/a (TODO-derived)
## Objective
Prove the save → list → open → return → delete loop in the browser (admin + anonymous views), run the regressions, and commit the phase.
## Work
1. `tests/e2e/test_chat_history.py` (new) — mock-only, DB up; admin login via `tests/e2e/auth_helpers.py` (the `ADMIN_PASSWORD` pattern):
- `test_save_and_see_history` — admin: on `/`, ask an on-topic question (a `test_chat_rag.py`-style phrasing) and wait for `done`; assert `#save-chat-btn` is visible; click it → the live region reads "Conversation saved."; navigate to `/history.html` → the table has a row for this chat: the auto-title (the question's whitespace-collapsed, ≤120-char text) and message count 2; **and** the API agrees (`httpx GET /api/chats` with the admin session cookie — the row exists with the right title).
- `test_open_chat_returns_to_history` — from the History row click the title → the URL is `/?chat=<uuid>`; the chat renders the saved conversation (the user question bubble + the brain answer with its source chips — the same answer text the History session saw); ask a **new** question and it streams fine (the conversation continues); press Save again → `GET /api/chats` (admin cookie) shows the **same single** row (the upsert) with the message count grown to 4.
- `test_new_chat_unlinks` — after the previous flow (or a fresh open): press New chat, then Save → the list now has **two** rows (a fresh create, not an update of the opened one).
- `test_delete_two_step` — History: click Delete on a row → the inline confirm pair appears (Playwright would hang on a real `window.confirm` — its absence is itself pinned); No → the row stays; Delete again, Yes → the row is gone; `GET /api/chats/<id>` (admin cookie) → 404; and `/?chat=<that id>` now shows the error banner + the local restore (the deleted-chat degradation).
- `test_anonymous_cannot` — a fresh context (no login): on `/` — `#save-chat-btn` not visible and `#nav-history` not visible; a direct `GET /history.html` — the page loads, the table shows the gated/empty state, and **no** `/api/chats` request was made (assert via `page.on("request")`); `httpx GET /api/chats` without the cookie → 403.
- DB isolation: saved-chat rows persist in the shared e2e DB across suites — each test uses a **distinctive question text** (so its auto-title is unique), never asserts on absolute row counts, and deletes the rows it creates in a `finally` (admin cookie).
2. `tests/e2e/test_cache_busting.py` — add `/history.html` to the pages that suite walks (the no-cache + `?v=` contract applies to the new page — the minimal diff to its page list).
3. Regression pass (isolation runs): `test_chat_persistence.py` (the boot path gained the `?chat=` branch), `test_nav_consistency.py` + `test_shared_header.py` (a seventh nav link — extend their link enumeration if they assert the exact nav set), `test_admin_auth.py` (the whoami gate unchanged), `test_cache_busting.py` (after the page-list addition).
4. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `uv run ruff check . && uv run pyright` clean.
5. Commit (Conventional Commits, `--no-gpg-sign`) — the message from the phase overview's Commit section — staging this phase's files; move `.agent/phases/todo/50_chat_history/` → `.agent/phases/complete/`.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_chat_history.py -v --no-cov` green in isolation.
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] All five story tests pass in isolation; the regression suites pass in isolation.
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.