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,41 @@
# Phase 49 — Retry the Last Answer (Redo)
**Source:** `TODO.md` L4 — "Need a retry button to retry the last answer, like a redo button"
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-08-29)
**Context:** `frontend/assets/app.js` — `handleSend` owns the whole turn (user-bubble append + conversation push → fetch/SSE → save points), `appendTuneButton` is the house pattern for a per-bubble meta-row action (reuses the `.msg-meta` row, the `role=list` → `role=listitem` rule, the admin gate), `conversation`/`rememberBrainTurn` hold the persisted records, and phase 48's Stop button means the last brain bubble can be a **stopped partial** — the natural retry candidate.
## Objective
A **Retry** button on the last brain answer re-asks the preceding question in place — the old answer is removed (DOM + persisted record), the fresh answer streams into its place, and the conversation never duplicates the question.
## Dependencies
- `48_stop_generation` (todo) — the button/state-machine work it builds on (the meta-row button family, the in-flight guard) and the stopped partials it makes retryable.
- `14_chat_persistence` (complete) — the conversation records the retry edits in place.
## Tasks
1. `01_retry_button.md` — the `runTurn` extraction + the Retry button + redo-in-place semantics + CSS.
2. `02_e2e_retry_answer.md` — the story Playwright suite + regressions + commit.
## Testing & Quality
- Frontend source pins (house pattern): the `runTurn(text, {reask})` extraction (reask skips the user append/push), last-bubble-only Retry management, the redo-in-place record edit, the in-flight no-op.
- Coverage: **>90%** on `app/` (unchanged — frontend-only phase).
- E2E (mandatory, A16): `tests/e2e/test_retry_answer.py`, run in isolation.
## Completion Criteria
- [ ] The last brain bubble (completed, deflected, empty-fallback, or stopped partial — admin and anonymous alike) carries a Retry button; earlier brain bubbles do not.
- [ ] Clicking Retry removes the old answer (DOM + `bor.chat.v1` record), re-sends the preceding question without duplicating it, and streams the fresh answer into its place; the new answer carries the Retry button (it is the new last).
- [ ] While a turn is in flight, Retry does nothing (no double turn).
- [ ] `uv run pytest` green; coverage TOTAL unchanged (>90%).
- [ ] `uv run pytest tests/e2e/test_retry_answer.py -v --no-cov` green in isolation (DB up).
- [ ] Regression E2E suites green in isolation: `test_chat_rag.py`, `test_chat_persistence.py`, `test_stop_generation.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 (2026-08-29, roadmap confirmation):** redo-in-place (the old answer is replaced, the question is not duplicated); only the **last** brain bubble is retryable; Retry is available to **all** visitors (chat is public — unlike Tune, which is admin-only); inert while a turn is in flight.
- **A10 untouched** — retry is a fresh `POST /api/chat` (the API stays stateless).
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
## Commit
```bash
git add -A .agent/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): retry the last answer — redo-in-place Retry button on the latest brain bubble"
```
@@ -0,0 +1,31 @@
# Task 01 — The Retry button (redo in place)
**Phase:** `49_retry_answer` · **Source:** `TODO.md:4` — "Need a retry button to retry the last answer, like a redo button"
**Story:** n/a (TODO-derived)
## Objective
`handleSend` is split so a question can be re-run without re-adding it, and a Retry button on the last brain bubble performs that redo: old answer gone (DOM + storage), fresh answer streaming into its place.
## Work
1. `frontend/assets/app.js`:
- **Extract `runTurn(text, { reask = false })`** from `handleSend`: everything from the `setUiState(thinking)` / `armTurnTimeout` point through the `finally` settle moves into `runTurn`; the `reask` flag skips (a) the `addMessage("user", …)` append and (b) the `conversation.push({ who: "user", … })` + `saveConversation()` (the question is already in both). `handleSend` keeps the form-level pre-work — the `!text` guard, the **in-flight guard that calls `stopTurn()`** (phase 48), `clearErrorBanner()`, the input clear + autoGrow — then appends + persists the user message and calls `runTurn(text, { reask })`. The turn-local resets (`acc`, `thinkingAcc`, `sawThinking`, `sawDone`, `toolAcc`, `stoppedByUser`, `turnAbort`) stay turn-scoped exactly as phase 48 left them. **No behavior drift for the normal send path**: a plain send must produce byte-identical DOM/SSE/persistence behavior to today (the regression suite is the proof).
- **`appendRetryButton(wrap)`** — the house `appendTuneButton` pattern for the meta row (reuse the `.msg-meta` row; `role=list` → `role=listitem` where the row is a list; one per bubble; an inline redo-glyph SVG + "Retry" text — the text is the accessible name). **Not** admin-gated (owner-locked: all visitors). Click handler `retryLastTurn(wrap)`:
- in-flight guard: if `uiState` is thinking/streaming → no-op (owner-locked).
- find the record: the last brain record in `conversation` (and that `wrap` is the rendered wrap of that record — the button only ever sits on the last bubble, but the guard keeps a stale click harmless).
- **redo in place:** pop the brain record from `conversation`; `saveConversation()` immediately (a crash between the pop and the fresh `done` must never resurrect the replaced answer — what the user saw, the removed answer, is what is stored; the question remains); remove `wrap` from the DOM; locate the preceding **user** record's `text` (the record immediately before the popped one — invariant: every brain record follows its user record); call `runTurn(text, { reask: true })`. No `sendStatus` banner; no scroll (phase-42 contract — no auto-scroll; the fresh bubble lands where the old one was).
- **Last-bubble-only management:** a helper `markLastRetryable()` — removes any existing `.retry-btn` from every rendered `.msg-meta`, then appends the Retry button to the last brain bubble (only when it has a preceding user record — always true in practice). Call sites: on `done` (after `appendTuneButton`), on the empty-answer fallback path, on the **stop finalize** path (phase 48 — the stopped partial is the prime retry candidate), and once at the end of `restoreConversation()` (after all records are rendered). `startNewChat`'s list reset removes everything anyway — no change there.
- **Header comment:** note the Retry contract (2026-08-29, `TODO.md` L4).
2. `frontend/assets/styles.css` — `.retry-btn`: the exact visual family of `.tune-btn` (same size/spacing/focus-visible/hover; ≥44px comfortable via the meta-row padding, as Tune has) with the redo glyph in the phase-08 palette (ink-soft → ink on hover), so the two meta actions read as a pair.
3. `frontend/index.html` — no markup change (the button is JS-injected like Tune); update the messages-section comment to mention the meta-row actions (Tune — admin; Retry — everyone).
4. Frontend source pins (house pattern, extend `tests/unit/test_frontend_feedback.py` or a sibling): the `runTurn` signature + the `reask` skips (no user append/push when reask); `appendRetryButton`'s no-admin-gate + one-per-bubble; `markLastRetryable`'s remove-then-append; the pop → save → rerun order in `retryLastTurn`; the in-flight no-op.
- ASSUMPTION (owner-locked 2026-08-29): redo-in-place — the old answer is replaced (DOM + persisted record), the question is re-sent without duplication; only the last brain bubble carries the button; available to all visitors; inert while a turn is in flight.
## Testing & Quality
- Unit: source pins as above; full suite green.
- Coverage: **>90%** on `app/` (unchanged — frontend-only).
## Completion Criteria
- [ ] The plain-send path is byte-identical to pre-task behavior (regression: `test_chat_rag.py` E2E green in isolation).
- [ ] Retry on a completed / deflected / stopped answer redoes in place (unit-pinned now, E2E in task 02).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,25 @@
# Task 02 — Retry E2E + regressions + commit
**Phase:** `49_retry_answer` · **Source:** `TODO.md:4` — "Need a retry button to retry the last answer, like a redo button"
**Story:** n/a (TODO-derived)
## Objective
Prove the redo contract in the browser, run the regressions, and commit the phase.
## Work
1. `tests/e2e/test_retry_answer.py` (new) — mock-only, DB up (conftest `page`; no login — chat is public):
- `test_retry_redoes_in_place` — ask an on-topic question (a `test_chat_rag.py`-style grounded question); wait for `done` (the source chips / Tune row appear — the turn is complete); ask a second on-topic question; assert: the **last** brain bubble carries a Retry button and the **first** brain bubble does not; click Retry on the last; assert: the old answer's text is gone from `#messages`; the retried question appears exactly **once** in the rendered list; a fresh answer is streaming (button "Stop"), then settles to the new answer; `bor.chat.v1` shape is `[u1, b1, u2, b2']` — the retried question occurs once and the last brain record is the fresh answer; the new bubble carries the Retry button.
- `test_retry_on_stopped_partial` — the phase-48 stop flow (a long-answer question, mid-stream stop → `.stopped-note`); the stopped bubble carries Retry; click it; the turn re-runs to a fresh `done` answer replacing the partial; the stored partial is gone (the last brain record has no `stopped` flag and carries the full answer).
- `test_retry_deflected` — an off-topic question that deflects (a `test_honest_deflection.py`-style question); the deflected bubble (with its Maybe-try chips) carries Retry; clicking it re-asks (still deflected is fine — assert the redo mechanics: old bubble gone, fresh deflected bubble in its place, the chips re-rendered).
- `test_retry_inert_while_in_flight` — seed one completed turn (so a last bubble exists), then ask a "pretend to think slowly …" question; while in flight (button "Stop"), click the Retry button on the previous last bubble; assert nothing happens: the in-flight turn still completes to its own `done`, and after settle there is exactly one user record for the in-flight question (no second turn started, no bubble duplication).
2. Regression pass (isolation runs): `test_chat_rag.py`, `test_chat_persistence.py` (the record shape is asserted there), `test_stop_generation.py` (phase 48 — the stop finalize path now also calls `markLastRetryable`).
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` TOTAL unchanged; `uv run ruff check . && uv run pyright` clean.
4. Commit (Conventional Commits, `--no-gpg-sign`) — the message from the phase overview's Commit section — staging this phase's files; move `.agent/phases/todo/49_retry_answer/` → `.agent/phases/complete/`.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_retry_answer.py -v --no-cov` green in isolation.
- Coverage: **>90%** on `app/` (unchanged).
## Completion Criteria
- [ ] All four story tests pass in isolation; the three regression suites pass in isolation.
- [ ] One atomic `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.