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,46 @@
# Phase 48 — Stop / Cancel an In-Flight Answer
**Source:** `TODO.md` L3 — "Need a way to stop or cancel generation of text in the chat"
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-08-29)
**Context:** A chat turn is `POST /api/chat` → an SSE stream (`thinking` → `tool` → `delta` → `done`/`error`) produced by `app/api/chat.py`'s `stream()` over `app.rag.agent.run_agent` (grounded turns) or `LLMClient.chat_stream` (deflected turns — `app/rag/llm.py`, an `AsyncOpenAI` streaming request). The frontend consumes it in `frontend/assets/app.js` (`handleSend` → `fetch` → `readSSE`) with the never-stale state machine (`setUiState`: idle → thinking → streaming → done|error → idle, PLAN §7.4) and the phase-14 localStorage conversation (`bor.chat.v1`; optional per-record fields like `thinking`/`tools`/`stopped` are the no-version-bump convention).
## Objective
The user can stop an in-flight answer at any time: while a turn is live the Send button becomes a **Stop** button; stopping keeps the partial answer on screen and in the persisted conversation (marked as stopped), settles the UI to idle with no error banner, and makes the server tear down the model's HTTP stream promptly so the local model stops generating.
## Dependencies
- `14_chat_persistence` (complete) — the `bor.chat.v1` conversation records + save points; the stopped partial persists through the same helpers (new optional `stopped` field).
- `17_thinking_display` + `20_sources_midstream_bug` (complete) — the thinking/tool frames + the "thinking-only turns persist nothing brain-side" convention the pre-token stop path follows.
- `37_agent_document_tools` (complete) — the agent loop grounded turns run in; its per-round streams must tear down on abandon too.
## Tasks
1. `01_llm_stream_teardown.md` — deterministic model-stream teardown on client disconnect + the cancelled-turn log line (no `query_log` row).
2. `02_stop_button.md` — the Send↔Stop one-button morph, `AbortController` abort, partial keep + `stopped` persistence + restore note; CSS.
3. `03_e2e_stop_generation.md` — the story Playwright suite + regressions + commit.
## Testing & Quality
- Unit: `tests/unit/test_llm_stream_teardown.py` (the openai stream is `aclose`d on full consumption AND on mid-iteration abandon), `tests/unit/test_chat_cancel.py` (an SSE turn torn down mid-stream: fake LLM stream closed, cancel log line written, no `query_log` row, `error`/`done` paths unchanged) — both follow the fake/override patterns of `tests/unit/test_chat_gate.py` + `tests/fakes.py`.
- Coverage: **>90%** on `app/` (validate.sh gate).
- Frontend source pins per the house pattern (`tests/unit/test_frontend_feedback.py` style): in-flight enabled + "Stop" label, `AbortController` signal, `stopped` record key, no-error stop path.
- E2E (mandatory, A16): `tests/e2e/test_stop_generation.py`, run in isolation.
## Completion Criteria
- [ ] While a turn is in flight the button reads **Stop** (rose treatment, ≥44px, focus-visible); clicking it (or pressing Enter) stops the turn.
- [ ] A mid-stream stop keeps the partial text, shows a "Stopped" note, no error banner; the `bor.chat.v1` record carries `stopped: true`; a reload restores it with the note.
- [ ] A pre-token stop leaves the question in the conversation, no brain bubble, no error banner.
- [ ] The model's stream is closed promptly on abandon (unit-proven); a cancelled turn logs `cancelled=true` and writes no `query_log` row; completed/error turns log and record exactly as before.
- [ ] `uv run pytest` green; coverage TOTAL >90%.
- [ ] `uv run pytest tests/e2e/test_stop_generation.py -v --no-cov` green in isolation (DB up).
- [ ] Regression E2E suites green in isolation: `test_chat_persistence.py`, `test_loading_feedback.py`, `test_chat_rag.py` (the loading-feedback suite's in-flight button assertions are revised in place to the new contract — see task 02).
- [ ] `uv run ruff check . && uv run pyright` clean.
- [ ] One `--no-gpg-sign` commit; phase dir moved to `.agent/phases/complete/`.
## Locked decisions
- **A10 untouched** — `/api/chat` stays stateless; stopping is a client disconnect, no new endpoint, no server session state.
- **Owner-locked (2026-08-29, roadmap confirmation):** (1) one-button morph — Send becomes Stop while in flight; click *or* Enter stops; (2) the partial answer is kept, persisted with the optional `stopped` marker, no sources, no error banner; a pre-token stop persists nothing brain-side (phase-20 convention); (3) the server closes the model stream on disconnect, logs `cancelled=true`, and skips `query_log` for cancelled turns.
- **Revised contract (owner-locked 2026-08-29):** the in-flight button is the Stop control — enabled, labeled "Stop", spinner hidden (the typing dots / Thinking block / tool lines remain the in-flight feedback per the phase-06/17 contract); `tests/e2e/test_loading_feedback.py` assertions that pinned the old disabled-"Thinking…"-button + visible-spinner state are updated in place to the new contract (its 120s-guard + live-region pins stay).
- **A16/A17 honoured** — one story E2E suite, one atomic commit.
## Commit
```bash
git add -A .agent/ app/ frontend/ tests/ && git commit --no-gpg-sign -m "feat(chat): stop an in-flight answer — Send becomes Stop, the partial is kept and persisted, the model stream is torn down"
```
@@ -0,0 +1,36 @@
# Task 01 — Deterministic model-stream teardown on client disconnect
**Phase:** `48_stop_generation` · **Source:** `TODO.md:3` — "Need a way to stop or cancel generation of text in the chat"
**Story:** n/a (TODO-derived)
## Objective
When the SSE consumer goes away (client disconnect / user stop), the app closes the aipi HTTP stream promptly — the local model stops generating — and the turn settles observably: one cancelled-turn log line, no `query_log` row, no half-emitted `done`.
## Work
1. `app/rag/llm.py` — `LLMClient.chat_stream`: make the openai SDK stream's lifetime explicit. Invariants the restructure must keep:
- a failure of the `create()` call itself wraps exactly as today (generic `except → LLMError` with the `chat stream from … failed` message);
- a failure inside the `async for` wraps exactly as today (`LLMError` re-raised, everything else wrapped);
- whenever `create()` succeeded, `await stream.aclose()` is awaited on **every** subsequent exit — normal exhaustion (a quiet no-op on an already-closed SDK stream, so the completed path stays byte-identical), exception paths, and GeneratorExit (consumer abandon — awaiting in the `finally` is safe because it does not yield).
- Update the docstring: the stream is closed on consumer abandon (stop/cancel — 2026-08-29, `TODO.md` L3).
2. `app/rag/agent.py` — `run_agent`: teardown must be deterministic, not GC-dependent. Per round, bind the stream to a variable (`stream = llm.chat_stream(...)`) and wrap the `async for piece in stream:` in `try/finally await stream.aclose()` — the same pattern for the round-cap's final `tools=None` call. The loop logic, the `holder` counters, and the `while True` structure are unchanged; a GeneratorExit raised into `yield piece` propagates only after the in-flight model stream is closed.
3. `app/api/chat.py` — `stream()`: add one terminal flag, `settled = False`, set `True` at **every** terminal exit (immediately before the `done` event yields; every `return` that follows an `error` event; the embed-failure and retrieval-failure returns; the 503 pre-stream path never enters `stream()` so it needs no flag). Add a `finally` block to `stream()`: `if not settled: logger.warning("chat: turn cancelled question=%r total_ms=%d", request.message, int((time.monotonic() - started) * 1000))` — the §9-style per-turn cancel line (owner-locked: cancelled turns log `cancelled=true` and skip `query_log` — the step-4 `QueryLog` write sits after the stream loop and is simply never reached when the generator is closed). The `finally` must not yield.
- Starlette closes the response-body generator (`aclose`) when the client disconnects; with tasks 1–2 in place the close chain is SSE generator → `run_agent`/`chat_stream` → aipi httpx response.
4. `tests/unit/test_llm_stream_teardown.py` (new) — follow the fake patterns in `tests/fakes.py`:
- a fake `AsyncOpenAI`-shaped client whose `chat.completions.create(stream=True)` returns a fake async stream that records `aclose()` calls and yields N pieces with small `asyncio.sleep`s between them;
- (a) full consumption → `aclose` called exactly once;
- (b) abandon after the first piece (`await gen.aclose()` on the `chat_stream` generator) → the fake stream's `aclose` was awaited before the generator's close completed.
5. `tests/unit/test_chat_cancel.py` (new) — follow the fixture approach of `tests/unit/test_chat_gate.py` (fake LLM + DB):
- drive `POST /api/chat` with a slow fake stream, read a few SSE frames, then drop the client mid-stream (close the client/connection the way the test harness permits — the SSE generator must receive `aclose`);
- assert: the fake LLM stream was closed, the log capture contains the cancel line ("turn cancelled"), and **no** `query_log` row exists for the question;
- regression pins: a completed turn still writes `query_log` + emits the `done` frame; the mid-stream `LLMError` path still emits the structured `error` frame and is **not** logged as cancelled (it settles).
## Testing & Quality
- Unit: as above; full suite green.
- Coverage: **>90%** on `app/` (the new branches in llm/agent/chat covered by the new tests).
## Completion Criteria
- [ ] Abandoning `chat_stream` mid-iteration closes the openai stream (unit-proven with the recording fake).
- [ ] `run_agent` closes the in-flight round's model stream when its consumer stops (unit-proven).
- [ ] A cancelled turn: cancel log line, no `query_log` row, no `done`/`error` frame after the disconnect.
- [ ] Completed/error turns behave byte-identically to before (existing `test_chat_gate.py` + friends green).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,38 @@
# Task 02 — The Stop button (Send↔Stop morph) + partial keep
**Phase:** `48_stop_generation` · **Source:** `TODO.md:3` — "Need a way to stop or cancel generation of text in the chat"
**Story:** n/a (TODO-derived)
## Objective
One-button control on the chat page: while a turn is in flight `#send-btn` becomes **Stop** (click or Enter aborts the turn); the partial answer is kept on screen and persisted (with the `stopped` marker), the UI settles to idle with a live-region confirmation and no error banner.
## Work
1. `frontend/assets/app.js`:
- **Abort plumbing:** at the start of a turn create `const ac = new AbortController()` and store it in module scope (`let turnAbort = null`, reset per turn, cleared in the `finally`); pass `signal: ac.signal` to the `fetch("/api/chat", …)` call. The 120s guard keeps `cancelStream(res)` as the backstop and additionally calls `turnAbort?.abort()` — same outcome, one owner.
- **`stopTurn()`:** new module function — a no-op unless `uiState` is thinking/streaming; sets a module-scope `stoppedByUser = true` (reset at turn start, next to `aborted`), then `turnAbort.abort()`. The abort makes the in-flight `fetch`/`await readSSE(...)` throw (`AbortError`) into `handleSend`'s `catch`, where the stop finalizes:
- in `catch (err)`: if `stoppedByUser` (or `err?.name === "AbortError"`) → **stop path**: no `showErrorBanner`. When `wrap` exists and `acc` is non-empty: `closeThinkingBlock(wrap)`, `appendTuneButton(wrap)` (parity with the restore path — admin-only, anonymous gets nothing), `appendStoppedNote(wrap)`, then `rememberBrainTurn(acc, { thinking: thinkingAcc || undefined, tools: toolAcc.length ? toolAcc : undefined, stopped: true })` (owner-locked optional `stopped` marker — phase-14 convention, `STORAGE_VERSION` stays 1). When there is no answer text yet (pre-token / thinking-only stop): persist **nothing** brain-side (phase-20 convention — the question is already saved on send). Set `sendStatus.textContent = "Answer stopped."` before the `finally` settles `idle` (the existing `finally`'s `setUiState(idle)` + focus-back remains the single settle path — the stop path must not double-settle, and it never scrolls: no `scrollReveal` on stop, phase-42 contract).
- **One-button morph in `setUiState`:** in-flight states (thinking/streaming) keep `#send-btn` **enabled** (it is the Stop button now — owner-locked), `sendLabel.textContent = "Stop"`, and `sendBtn.classList.toggle("is-stop", inFlight)`. The spinner is hidden while in flight (`sendBtn.querySelector(".spinner").hidden = inFlight` — the label alone reads "Stop"; the CSS treatment carries the state). Idle/error: existing behavior (label "Send", class removed, spinner hidden as today).
- **Tool frames:** the phase-37 `tool` branch no longer writes `sendLabel.textContent = "Calling tool…"` — the button stays "Stop" while in flight (owner-locked); the "calling tool" status stays exactly where it is today in `#send-status` + the typing indicator's `aria-label`.
- **Submit-while-in-flight:** `handleSend`'s first guard becomes `if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) { stopTurn(); return; }` (the button is enabled while in flight, so both the click and Enter-to-submit land here; the `!text` guard for idle stays).
- **Restore:** `renderStoredMessage` — when `m.stopped` is true, call the same `appendStoppedNote(wrap)` helper. New small helper: creates/reuses the `.msg-meta` row exactly like `appendTuneButton` does (including the `role=list` → button/`span` `role=listitem` rule) and appends a `.stopped-note` span — an inline stop-glyph SVG (a small filled square, `aria-hidden="true"`) + the text "Stopped" (the text carries the accessible meaning).
- **Header comment:** update the file's top comment — the loading-feedback machine gains a user-stop terminal (stop → idle, no banner) and the button's dual role (2026-08-29, `TODO.md` L3).
2. `frontend/assets/styles.css`:
- `.send-btn.is-stop` — the stop treatment in the phase-08 dark-tech palette: a rose-family background derived from the brand rose `#f43f5e` but darkened so the near-white label keeps ≥4.5:1 (check the contrast — e.g. a `#be123c`-range token with `#fff` label), the same border-radius/height/focus-visible ring as `.send-btn`, a darker hover step, hit target ≥44px unchanged.
- `.stopped-note` — the meta-row note: muted ink-soft color (≥4.5:1 on the bubble surface), 10–12px glyph baseline-aligned with the Tune button, non-interactive (no hover/focus).
3. `frontend/index.html` — no markup change (`#send-btn` already carries spinner + `#send-label`); update the composer comment block to document the Send↔Stop morph + the stopped-note meta row.
4. **Revise the old contract in place** — `tests/e2e/test_loading_feedback.py`: its pins of the in-flight button (disabled, "Thinking…" label, visible spinner) are updated to the new contract (enabled "Stop" button, `is-stop` class, spinner hidden); its 120s-guard, live-region, and typing-dots pins stay intact. The unit pins in `tests/unit/test_frontend_feedback.py` that assert the same old button state are updated in place the same way (they live with the `SEND_STATUS`/`setUiState` pins — keep the file's structure).
5. Frontend source pins (house pattern, `tests/unit/test_frontend_feedback.py` style — extend that file): the in-flight `is-stop` toggle + enabled button + "Stop" label; the `AbortController` + `signal` in the fetch; the `stoppedByUser` stop branch (no `showErrorBanner`); the `stopped: true` key in the persisted record; the `appendStoppedNote` restore path; the tool branch no longer writing `sendLabel`.
- ASSUMPTION (owner-locked 2026-08-29): one-button morph — click or Enter while in flight stops the turn.
- ASSUMPTION (owner-locked 2026-08-29): the partial answer is kept, persisted with the optional `stopped` marker (no sources/suggestions), "Stopped" note, no error banner; a pre-token stop persists nothing brain-side; the live stopped bubble gets the Tune button for admin (parity with the restore path).
- ASSUMPTION (owner-locked 2026-08-29): the server side of a stop is the task-01 teardown — this task never calls a cancel endpoint (there is none — A10).
## Testing & Quality
- Unit: source pins as above; full suite green.
- Coverage: **>90%** on `app/` (unchanged by this frontend task — the phase's app coverage came from task 01).
## Completion Criteria
- [ ] In flight: button enabled, label "Stop", `is-stop` class; idle: "Send" (state machine otherwise unchanged — the revised `test_loading_feedback.py` green).
- [ ] Click/Enter while in flight aborts; the partial is kept + persisted (`stopped: true`); no error banner; the live region confirms "Answer stopped.".
- [ ] A reload restores the stopped answer with the Stopped note (unit-pinned now, E2E in task 03).
- [ ] `uv run pytest` green; `uv run ruff check . && uv run pyright` clean.
@@ -0,0 +1,25 @@
# Task 03 — Stop E2E + regressions + commit
**Phase:** `48_stop_generation` · **Source:** `TODO.md:3` — "Need a way to stop or cancel generation of text in the chat"
**Story:** n/a (TODO-derived)
## Objective
Prove the stop contract in the browser (mid-stream stop, pre-token stop, persistence of the stopped turn), run the regressions, and commit the phase.
## Work
1. `tests/e2e/test_stop_generation.py` (new) — mock-only, DB up (conftest `page` fixture; no admin login needed — chat is public):
- `test_stop_mid_stream` — ask an on-topic question carrying `LONG_ANSWER_TRIGGER` ("write a long answer …" — reuse the on-topic phrasing `test_long_answers.py` uses so the honesty gate is HIGH); wait for the brain bubble to exist and its text to grow past a few words (first deltas); assert in flight: `#send-btn` enabled, label "Stop", class `is-stop`; click it; assert: label back to "Send" and `is-stop` gone; **no** error banner (`#kb-banner` without `.is-error`/`role="alert"`); the bubble keeps the partial text and shows a `.stopped-note` "Stopped"; the text is stable (re-read after ~1.5 s — no growth) and shorter than the mock's full long answer (`LONG-ANSWER-END` absent from the bubble); `bor.chat.v1`'s last brain record has `stopped === true` and a `text` lacking `LONG-ANSWER-END`.
- `test_stop_pre_token` — ask an on-topic question containing "pretend to think slowly" (the mock's 3s warm-up); while the button reads "Stop" (thinking state) click it; assert: no error banner, **no** brain bubble in `#messages`, `bor.chat.v1`'s last record is the user's question (`who: "user"`), button "Send" + enabled, input focused.
- `test_stopped_turn_survives_reload` — the `test_stop_mid_stream` flow, then `page.reload()`; the stopped answer restores with the `.stopped-note`, the conversation order is intact (user, then the stopped brain bubble), the button is idle "Send".
- Determinism notes: the mock streams 12 chars / 0.02 s, so the long answer takes ~8 s — a comfortable stop window; wait for observable states, no fixed sleeps beyond those.
2. Regression pass (isolation runs): `test_chat_persistence.py` (save/restore + New chat — the button and state machine changed), `test_loading_feedback.py` (the revised contract — 120s guard + live regions + typing dots), `test_chat_rag.py` (a normal turn still streams to `done` with sources + Tune).
3. `uv run pytest` green; `uv run pytest --cov=app --cov-report=term-missing` >90%; `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/48_stop_generation/` → `.agent/phases/complete/`.
## Testing & Quality
- E2E: `uv run pytest tests/e2e/test_stop_generation.py -v --no-cov` green in isolation.
- Coverage: **>90%** on `app/`.
## Completion Criteria
- [ ] All three 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/`.