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.
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) — thebor.chat.v1conversation records + save points; the stopped partial persists through the same helpers (new optionalstoppedfield).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
01_llm_stream_teardown.md— deterministic model-stream teardown on client disconnect + the cancelled-turn log line (noquery_logrow).02_stop_button.md— the Send↔Stop one-button morph,AbortControllerabort, partial keep +stoppedpersistence + restore note; CSS.03_e2e_stop_generation.md— the story Playwright suite + regressions + commit.
Testing & Quality
- Unit:
tests/unit/test_llm_stream_teardown.py(the openai stream isaclosed 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, noquery_logrow,error/donepaths unchanged) — both follow the fake/override patterns oftests/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.pystyle): in-flight enabled + "Stop" label,AbortControllersignal,stoppedrecord 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.v1record carriesstopped: 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=trueand writes noquery_logrow; completed/error turns log and record exactly as before. uv run pytestgreen; coverage TOTAL >90%.uv run pytest tests/e2e/test_stop_generation.py -v --no-covgreen 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 pyrightclean.- One
--no-gpg-signcommit; phase dir moved to.agent/phases/complete/.
Locked decisions
- A10 untouched —
/api/chatstays 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
stoppedmarker, 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, logscancelled=true, and skipsquery_logfor 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.pyassertions 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"