fix(cli): unstick streamingState after Esc-cancel + close leaked turn span#145
Merged
Conversation
… span
Symptom (also reported on the Langfuse trace, session 442ed5c7,
turn ba924d250d7c with 739s latency and zero LLM activity in the
middle): user presses Esc to cancel, then any subsequent input is
silently dropped — UI stays in the loading indicator forever.
Root cause:
- cancelOngoingRequest aborts the AbortController and flips
isResponding=false, but does not clear toolCalls. If a tool
ignores its AbortSignal, it stays in 'executing' / 'scheduled' /
etc., or finishes with responseSubmittedToGemini=false. Either
way, streamingState computes 'Responding' (useGeminiStream.ts:
424-437) and submitQuery's guard (1305-1314) silently drops the
next user submission. The in-code comment at line 244-245 even
flags this class of bug for a different cause.
- cancelOngoingRequest never calls endTurnSpan, so the OTel turn
span leaks. The recap + prompt-suggestion LLM calls that fire on
streamingState=Idle then attach to the dead span — Langfuse
reports the turn as 12 minutes long when the actual model work
was 1.7 seconds.
Inheritance: upstream qwen-code has the identical cancelOngoingRequest
shape and the identical silent-return guard. gemini-cli upstream's
PR #21960 (closing #21096) addressed a different cancel-related bug
(retry-loop loading indicator showing stale "still on it" text), not
the stuck-toolCalls case. Issue #18525 there ("Agent Stuck between
Responses") is essentially the same symptom and is still open. So
this is inherited, not introduced — but the leaked turn span is
fork-only, since startTurnSpan/endTurnSpan are part of our agent
harness.
Fix:
- useReactToolScheduler exposes a new forceCancelStaleToolCalls()
that flips responseSubmittedToGemini=true on terminal calls and
synthesizes a 'cancelled' state for any non-terminal call (with a
clear "User cancelled. Tool was force-cleared after the abort
signal did not stop it within the grace window" message in the
responseParts so downstream consumers don't choke).
- cancelOngoingRequest in useGeminiStream:
* marks every current toolCall as submitted immediately (handles
the common case where the tool finished but the flag wasn't
flipped),
* schedules a 3s setTimeout that calls forceCancelStaleToolCalls
and surfaces a WARNING if anything had to be force-cleared so
the user knows the underlying process may still be running,
* calls endTurnSpan('ok') so the recap/suggestion LLM calls don't
keep nesting under a dead turn span in Langfuse.
- submitQuery no longer silently drops submissions when streamingState
is non-Idle. It logs a clear WARNING explaining what state we're
in (Responding / WaitingForConfirmation / Backgrounded) and what
the user should do (approve the tool, wait, or press Esc).
Tests: 3,767 cli tests + 5,337 core tests pass. Existing cancellation
tests in useGeminiStream.test.tsx (49 tests in that file) continue to
pass with the new flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThe changes add a cancellation mechanism for stale tool calls. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
mabry1985
pushed a commit
that referenced
this pull request
Apr 28, 2026
Two leaks in processGeminiStreamEvents that #145 / #152 didn't cover: 1. case ServerGeminiEventType.UserCancelled fell through with a plain `break`, which only exits the switch — the for-await kept iterating and any toolCallRequests already collected this iteration would still get scheduled at the post-loop scheduleToolCalls call. 2. The post-loop scheduleToolCalls fired unconditionally. If abort landed in the same tick as a chunk that carried finish_reason=tool_calls (model emitted tool calls while user was hitting Esc), the scheduler added the tools in 'validating' state with an already-aborted signal. The per-tool aborted check at coreToolScheduler.ts:861 eventually marks them 'cancelled', but the React state briefly flips streamingState back to Responding — sticking the spinner until the 3s forceCancelStaleToolCalls grace window catches up. Fix: UserCancelled returns early; scheduleToolCalls is gated on !signal.aborted. Two new tests in the Cancellation describe block cover both arms (UserCancelled-then-toolCallRequest, and toolCallRequest-arriving-after-abort). 51/51 useGeminiStream tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Reproduced on Langfuse: session
442ed5c7, turnba924d250d7c. The trace shows a 739-second turn with zero LLM activity in the middle — only theturnroot span and two LLM calls clustered at the very end (which turn out to be the recap + prompt-suggestion calls firing onstreamingState=Idle). The 12 minutes weren't model work; the OTel turn span just leaked open and got force-closed when the next prompt finally went through.User-side, the symptom was: press Esc to cancel a turn → anything typed afterwards is silently dropped → app feels hung.
Root cause (two bugs interacting)
cancelOngoingRequestdoesn't clear stuck toolCalls. It aborts the controller and flipsisResponding=false, but if a tool ignores itsAbortSignal(or finishes between the cancel firing andresponseSubmittedToGeminibeing set), the toolCall stays in a non-terminal state.streamingState(useGeminiStream.ts:424-437) computesResponding, andsubmitQuery's guard (1305-1314) silentlyreturns every subsequent submission. The in-code comment at line 244-245 already documents this class of bug for a different root cause.cancelOngoingRequestdoesn't callendTurnSpan, so the OTel turn span leaks. The recap +usePromptSuggestionsLLM calls that fire onstreamingState=Idlethen nest under the dead span — Langfuse reports the turn as 12 minutes long when the actual model work was 1.7s.Inheritance check
QwenLM/qwen-codemain: identicalcancelOngoingRequestshape, identical silent-return guard. Same bug. Issue #914 closed without resolution.google-gemini/gemini-cli: PR #21960 (which closed #21096) fixed a different cancel-related issue — retry-loop loading indicator showing stale "still on it" text. Not the same fix. Open issue #18525 ("Agent Stuck between Responses") is essentially this same symptom, still unresolved.startTurnSpan/endTurnSpanand theactiveTurnContextare part of our agent harness (seedocs/architecture/divergence-from-upstream.md).Fix
useReactToolSchedulerexposesforceCancelStaleToolCalls()— flipsresponseSubmittedToGemini=trueon terminal calls and synthesizes acancelledstate for non-terminal calls (with a "User cancelled. Tool was force-cleared after abort signal did not stop it within the grace window" message inresponsePartsso downstream consumers don't choke).cancelOngoingRequest:markToolsAsSubmittedon every current toolCall immediately (handles the common case),setTimeoutthat runsforceCancelStaleToolCallsand surfaces a WARNING if anything had to be force-cleared,endTurnSpan('ok')so the recap/suggestion calls don't attach to the dead span.submitQuery: when dropping a submission becausestreamingState !== Idle, surfaces a clear WARNING explaining the state (Responding/WaitingForConfirmation/Backgrounded) and the next step. No more silent drops.Tests
useGeminiStream.test.tsx's 49-test cancellation suite continues to pass with the new flow.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features