fix(cli): cancel must stop the tool-result feedback loop#152
Merged
Conversation
Reported: the recap card fires after slash commands like /commit and the model has nothing to summarize, so it hallucinates output like "I don't have access to any previous conversation — this appears to be the start of our session." Cause: useRecap watches streamingState transitions and counts UI-level tool_group entries to decide if a turn was tool-heavy. Slash commands do flip streamingState and do produce many tool_group entries, but they bypass GeminiChat — `geminiClient.getHistory()` is empty when the recap fires. generateRecap then ships its prompt with no prior context and the model can only hallucinate. Fix: gate the recap on the conversation containing at least one model-role AND one user-role entry. Slash-command-only turns short-circuit before the LLM call. Real LLM turns (single or multi-turn) still recap as before. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The earlier fix unblocked streamingState by force-cancelling stuck
toolCalls in the UI display state, but it missed the upstream cause
of why submitQuery kept dropping new prompts: handleCompletedTools
was still feeding tool results back to the model even after the user
cancelled.
Repro from Langfuse session d795ff07 at 01:11:47Z:
- User starts a turn. Turn span opens (4.4s)
- User presses Esc, cancelOngoingRequest fires:
- abort signal sent
- turnCancelledRef = true
- endTurnSpan('ok') closes the turn span
- But a shell command was already in-flight. It runs another 11.4s
and finishes with status='success' (didn't honor abort)
- handleCompletedTools fires for the completed shell. The existing
isResponding guard passes (cancel set it false). The function
decides this is a legitimate gemini-bound tool result and calls
submitQuery(SendMessageType.ToolResult, ...)
- submitQuery(ToolResult) at line 1341:
abortControllerRef.current = abortController; // fresh, not aborted
turnCancelledRef.current = false; // cancel undone
- Loop continues. New LLM call (3.9s) at 01:12:03, then another
(1.2s) at 01:12:07 — all OUTSIDE any turn span (orphan), and the
streamingState ping-pongs between Idle and Responding.
- Every user-typed prompt during this cascade hits submitQuery's
silent-drop guard at line 1305 because streamingState !== Idle
at the moment of submission.
Net symptom: "any time Request Cancelled I cant restart" — exactly
the user's report.
Fix: handleCompletedTools, immediately after the isResponding guard,
checks turnCancelledRef.current. If the user already cancelled, it
marks the completed tools as submitted (so streamingState clears)
but does NOT call submitQuery. The loop ends where the user wanted
it to. Tools that finished after the abort signal but before cleanup
have their results discarded — the desired behavior for an explicit
cancel.
Tests: 3,767 cli pass; 49 cancellation tests in useGeminiStream.test.tsx
remain green.
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)
WalkthroughTwo UI hooks were modified: one adds early-exit logic to prevent re-feeding tool results to Gemini after user-initiated cancellation, and another adds conversation history validation before recap generation proceeds. 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 the previous fix didn't fully work
The earlier PRs (#145, #149) cleaned up the visible UI state after cancel — toolCalls force-cleared, recap suppressed for slash commands, leaked turn span closed. But the root reason cancel didn't stick was further upstream: `handleCompletedTools` kept feeding tool results back to the model after cancel, which reset `turnCancelledRef.current = false` at `useGeminiStream.ts:1343` when `submitQuery(SendMessageType.ToolResult)` ran.
Repro from Langfuse session d795ff07 at 01:11:47Z
User's exact phrasing: "any time Request Cancelled I cant restart".
Fix
`handleCompletedTools`, immediately after the `isResponding` guard, checks `turnCancelledRef.current`. If true:
Tests
🤖 Generated with Claude Code
Summary by CodeRabbit