Files
brain-of-reese/.agent/phases/complete/48_stop_generation/00_phase.md
T
ducoterra 4971e2859d
Build and Push Containers / build-and-push-app (push) Successful in 12s
Build and Push Containers / build-and-push-db (push) Successful in 10s
chore(agent): track .agent/ planning tree in git
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.
2026-09-01 10:18:22 -04:00

5.3 KiB

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 aclosed 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

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"