feat: add a stop control to the Library Assistant (sc-42230) - #203
feat: add a stop control to the Library Assistant (sc-42230)#203stevekaplan123 wants to merge 5 commits into
Conversation
📊 Code Quality Score: 75/100
Was this score accurate? 👍 Yes · 👎 No Scored by GitVelocity · How are scores calculated? |
There was a problem hiding this comment.
🟡 Changes recommended
Several lifecycle races can lose acknowledged cancellations, persist completed answers, or leave the agent running and billing.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds a stop control that cancels in-flight Library Assistant turns across the Svelte UI, Django API, and Claude agent lifecycle.
Changes:
- Adds stop-state UI, prompt restoration, and cancellation-aware streaming.
- Persists cancellation state and propagates it to the agent subprocess.
- Adds backend cancellation and SDK lifecycle tests.
File summaries
| File | Description |
|---|---|
src/lib/api.js |
Adds stream abort support and cancellation API client. |
src/components/LCChatbot.svelte |
Implements stop UI and stopped-message rendering. |
server/chat/V2/views.py |
Adds cancellation endpoint and stream propagation. |
server/chat/V2/agent/turn_orchestrator.py |
Adds cancellation checkpoints between phases. |
server/chat/V2/agent/sdk_runner.py |
Stops SDK processing when cancellation is observed. |
server/chat/V2/agent/contracts.py |
Defines cancellation contracts and exception. |
server/chat/V2/agent/claude_service.py |
Passes cancellation checks into orchestration. |
server/chat/V2/agent/__init__.py |
Exports the cancellation exception. |
server/chat/urls.py |
Registers the cancellation endpoint. |
server/chat/tests/test_streaming_integration.py |
Tests endpoint and streaming cancellation behavior. |
server/chat/tests/test_serializers.py |
Updates history serializer expectations. |
server/chat/tests/test_sdk_runner_streaming.py |
Tests SDK cancellation and client closure. |
server/chat/serializers.py |
Adds cancel requests and history processing state. |
server/chat/models.py |
Adds the cancelled processing state. |
server/chat/migrations/0012_alter_chatmessage_processing_state.py |
Updates processing-state choices. |
Review details
- Files reviewed: 15/15 changed files
- Comments generated: 7
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
All three surfaced in review of #203. The SSE loop read the cancel flag only inside its `except queue.Empty` branch, so a turn emitting progress continuously — the normal case for a working agent — never reached the check and ran to completion, and cost, after the user pressed stop. The flag is now polled on an elapsed-time cadence at the top of every iteration, at the same rate as before. The disconnect handler relays the flag as well, because the browser hangs up as soon as its cancel POST returns, frequently sooner than the next poll, and future.cancel() cannot interrupt a thread already running. That relay is deliberately conditional on the flag: an ordinary disconnect (closed tab, dropped connection) must still finish and persist, or chat_recover_v2 has nothing to hand back. The SSE parser had no branch for the server's new `event: cancelled`. When that event won the race against the client's own abort() the stream ended without a final message, fell into missing-final recovery, and marked a message the user had deliberately stopped as FAILED. It now surfaces as an AbortError, reusing the single deliberate-stop path the component already has rather than adding a second one to keep in sync. ClaudeSDKRunner.run() checked the flag only per streamed message, after opening the client and submitting the query. run() is entered a second time for the link-repair pass, and link validation resolves every ref in the answer over the network one at a time — a multi-second gap at exactly the point a waiting user gives up — so a stop landing there still bought a full repair query. Now checked before the client opens, with a matching checkpoint at the top of the repair loop. Tests: two integration tests covering the busy-stream and client-hangup paths, and one asserting a cancelled turn opens no SDK client. All three were confirmed to fail against the unfixed code. test_run_raises_turn_cancelled_and_closes_client now keys off messages delivered instead of a count of should_cancel() calls, which the new pre-query check shifts by one; it still asserts the same behaviour and still passes against the old runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The allowed guardrail path can start a billable router request after cancellation has already been observed.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Balanced
| if guardrail_response: | ||
| # The guardrail may have been in flight when the user stopped, and | ||
| # this early return would otherwise skip every later checkpoint. | ||
| raise_if_cancelled() | ||
| return guardrail_response | ||
|
|
||
| router_prompt_id, route, messages = await self.router.run_router( |
| from typing import Any, Protocol | ||
|
|
||
|
|
||
| class TurnCancelled(Exception): |
There was a problem hiding this comment.
Nit: per PEP 8, exceptions get an Error suffix when they represent an error condition. Python's own cancellation exceptions (asyncio.CancelledError, concurrent.futures.CancelledError) keep that suffix even though a cancellation isn't a bug — worth following that precedent here for consistency: TurnCancelledError.
| core_prompt_id: str | None = None, | ||
| on_progress: Callable[[AgentProgressUpdate], None] | None = None, | ||
| context: MessageContext | None = None, | ||
| should_cancel: CancelCheck | None = None, |
There was a problem hiding this comment.
Follow-up on the naming nit: since every call site does if should_cancel and should_cancel():, consider defaulting this to a no-op instead of None so the call sites can drop the should_cancel and guard and just do if should_cancel():. The default has to return False (never cancel) — a lambda: True default would cancel every turn on its first poll. Prefer a named function over an inline lambda per PEP 8 (E731):
def _never_cancel() -> bool:
return Falsethen should_cancel: CancelCheck = _never_cancel.
This also lets the type annotation drop the | None — with a real default, should_cancel is never None, so it can just be should_cancel: CancelCheck = _never_cancel instead of CancelCheck | None = None.
The if should_cancel and should_cancel(): guard is already duplicated three times in the codebase as-is: twice inline in sdk_runner.py, and once wrapped in raise_if_cancelled() in turn_orchestrator.py. If this default isn't adopted, at least don't add further copies of the repeated guard — keep new checkpoints going through raise_if_cancelled() rather than re-inlining the should_cancel and should_cancel() pattern.
| handling this request is not necessarily the pod streaming the turn, and the | ||
| streaming pod polls this row (see STREAM_CANCEL_POLL_SECONDS). | ||
| """ | ||
| serializer = CancelRequestSerializer(data=request.data) |
There was a problem hiding this comment.
This function adds the 5th copy of the serializer-validation block and the 4th copy of the actor-authentication block in this file (chat_stream_v2, chat_recover_v2, chat_client_event_v2, chat_feedback_v2 all repeat one or both verbatim). Extract a decorator that does both and short-circuits before the view body runs:
def validated_view(serializer_cls, *, authenticate=True):
def decorator(view_func):
@wraps(view_func)
def wrapper(request):
serializer = serializer_cls(data=request.data)
if not serializer.is_valid():
return Response(
{"error": "Invalid request", "details": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
data = serializer.validated_data
if not authenticate:
return view_func(request, data)
actor = _authenticate_actor_or_response(request, data)
if isinstance(actor, Response):
return actor
return view_func(request, data, actor)
return wrapper
return decoratorApply it to chat_cancel_v2 here and to the other four views in this file. chat_cancel_v2 becomes:
@api_view(["POST"])
@validated_view(CancelRequestSerializer)
def chat_cancel_v2(request, data, actor):
...Cut the ~30 lines of repeated boilerplate across the file now rather than adding a 5th and 6th copy of it later.
|
|
||
| /* Outlined counterpart to the filled send button it replaces; same footprint | ||
| so the footer doesn't shift when the two swap. */ | ||
| .stop-btn { |
There was a problem hiding this comment.
.stop-btn re-declares most of .send-btn's layout instead of sharing it: display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: var(--lc-radius-sm); cursor: pointer; are identical between the two, and :active:not(:disabled) { transform: scale(0.95); } is byte-for-byte the same rule in both places. Only the fill (background/border/color) should actually differ between a filled and an outlined button.
Merge the shared shape/interaction rules into one selector:
.send-btn, .stop-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--lc-radius-sm);
cursor: pointer;
}
.send-btn:active:not(:disabled),
.stop-btn:active:not(:disabled) {
transform: scale(0.95);
}and leave each button's own rule with only what actually differs (fill vs. outline, its own :hover/:disabled colors).
Separately: .stop-btn has a :focus-visible outline and .send-btn doesn't, even though they occupy the same spot in the footer. Add the same :focus-visible rule to .send-btn while touching this block.
| should_cancel=should_cancel, | ||
| ) | ||
| output = repair_result.final_text.strip() or ERROR_FALLBACK_MESSAGE | ||
| validation_result = await validator.validate_response(output) |
There was a problem hiding this comment.
Both exit paths of this loop — validation passing on the first try, and validation still failing after the repair attempt is exhausted (MAX_RESPONSE_LINK_REPAIR_ATTEMPTS = 1) — go straight from the last await validator.validate_response(...) to build_agent_response(...) with no raise_if_cancelled() in between. The loop's own comment above explains why that call is exactly the kind of gap this pattern is meant to close ("Link validation resolves every ref in the answer over the network... exactly when a waiting user gives up and hits stop"), but the checkpoint only guards the way into the repair branch, not the exit on either path.
This doesn't cause a persistence bug — views.py's independent DB-flag check before persisting (if result_holder["cancelled"] or _is_turn_cancelled(...)) catches it regardless of whether TurnCancelled was raised here, which is exactly what test_response_returned_after_cancel_is_discarded verifies. But it does mean a turn that's about to be discarded still runs trace_logger.log_success() and finishes computing metrics for an answer nobody will see, purely because this one checkpoint is missing. A raise_if_cancelled() right after the while loop, before the metrics/logging block, closes it.
Separately: this file (turn_orchestrator.py) has no dedicated test file at all — nothing in this PR or before it exercises run_turn's checkpoint placement directly. Every cancellation test in this PR goes one layer below (sdk_runner.py's per-message check) or one layer above (views.py, where agent.send_message is mocked with AsyncMock(side_effect=TurnCancelled()), bypassing this file's internals entirely). That's why this gap isn't caught by the suite — nothing here can catch it.
— Claude
| return; | ||
| } | ||
|
|
||
| streamAbortController?.abort(); |
There was a problem hiding this comment.
cancelStream()'s return value is discarded here, and isSending is the only guard left before abort() fires. cancelStream tells you exactly what happened server-side — 'cancelling' vs 'not_running' (the latter meaning _mark_turn_cancelled found the row already outside STARTED/RUNNING, i.e. the turn already completed). Throwing that away means the only thing standing between this abort() and a finished turn is isSending, which only flips false once the SSE event: message has been fully received and parsed client-side.
There's a real window between those two signals. In views.py, _mark_turn_completed() runs (line ~776) well before event: message is actually yielded (line ~867) — building the payload, stats, and trace logging happens in between, plus normal network transit on top. If stop is clicked inside that window: the server correctly reports not_running, but isSending is still true client-side, so this falls through to abort(). sendMessageStream's reader loop then re-throws the resulting AbortError immediately, without attempting recovery (recovery is deliberately skipped on abort — "a response nobody wants"). A fully generated, already-persisted answer gets discarded and replaced with the synthetic "Stopped generating..." note, even though chat_recover_v2 would return it as status: 'complete' if asked. It's self-correcting on a full reload (history sync reads processingState: 'completed', not 'cancelled'), but nothing re-triggers that mid-session.
Suggest checking cancelStream's result: if it comes back not_running, don't abort — clear isStopping and let the in-flight message land naturally.
Separately: no .test.js/.spec.js files are touched anywhere in this PR, even though it adds substantial client logic (handleStop, cancelStream, the abort/recovery interaction). This race — and the rest of the stop-button flow — has zero test coverage on the frontend.
— Claude
What this does
Adds a stop button to the Library Assistant. Today, once you send a question there is no way to take it back — you wait for an answer you may not want, and we pay Anthropic for every token of it.
While the assistant is generating, the send arrow becomes a stop button. Clicking it abandons the turn, shuts down the agent, and puts your question back in the input box so you can edit it and resend.
How it works
The cancel flag lives in Postgres, not in memory.
POST /api/v2/chat/cancelwritesprocessing_state = "cancelled"onto the user's message row; the streaming pod polls that row once a second and relays it to the agent thread via athreading.Event.Stopping the model costs money until the subprocess dies. sdk_runner.py checks the flag once per streamed message and raises
TurnCancelled, which unwinds out of the SDK'sasync withblock — that close is what terminates the agent subprocess and stops the billing. turn_orchestrator.py adds checkpoints between phases so a stop is honored even when the turn is between model calls.Nothing is persisted for a cancelled turn. No assistant message is saved. The persistence step re-reads the database rather than trusting the in-memory result, because the agent can still return an answer after a cancel (an early return that skipped the checkpoints, or a turn that finished a moment before the click). On reload, the client rebuilds the stopped note from the
processingStatenow returned with history.Tests
~450 new lines covering the cancel endpoint (auth, 404, idempotency, completed turns left alone), the streaming endpoint's cancelled path, end-to-end propagation, an answer arriving after a cancel being discarded, and SDK-runner tests proving the client actually closes.
Note for reviewers
In models.py the
processing_*fields moved a few lines up and the hand-writtenchoices=[…]list was replaced withProcessingState.choices. The diff makes it look like fields were deleted — they were only relocated. The one behavioral change is the addedcancelledchoice.🤖 Generated with Claude Code