Skip to content

fix(cli): cancel must stop the tool-result feedback loop#152

Merged
mabry1985 merged 2 commits into
devfrom
fix/recap-skip-on-empty-history
Apr 27, 2026
Merged

fix(cli): cancel must stop the tool-result feedback loop#152
mabry1985 merged 2 commits into
devfrom
fix/recap-skip-on-empty-history

Conversation

@mabry1985
Copy link
Copy Markdown

@mabry1985 mabry1985 commented Apr 27, 2026

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

  1. Turn opens (4.4s span ✓)
  2. User presses Esc → abort signal sent, `turnCancelledRef = true`, `endTurnSpan('ok')` closes the span
  3. A shell command was already in-flight; runs another 11.4s with status='success' (didn't honor abort)
  4. `handleCompletedTools` fires for the completed shell. `isResponding` guard passes (cancel set it false). Tool is gemini-bound and not all-cancelled → `submitQuery(ToolResult)` runs
  5. `submitQuery` line 1341 wires up a fresh AbortController and resets `turnCancelledRef = false` — cancel is undone
  6. Loop continues — new LLM call (3.9s), then another (1.2s), all outside any turn span (orphan in Langfuse) and `streamingState` ping-pongs
  7. Every prompt the user types during this cascade hits `submitQuery`'s silent-drop guard at line 1305 because `streamingState !== Idle` at the moment

User's exact phrasing: "any time Request Cancelled I cant restart".

Fix

`handleCompletedTools`, immediately after the `isResponding` guard, checks `turnCancelledRef.current`. If true:

  • Mark all completed tools as submitted (so `streamingState` clears).
  • Do not call `submitQuery` — the loop ends where the user wanted it to.
  • Tool results that landed after cancel are discarded. That's the desired semantics for an explicit cancel.

Tests

  • `useGeminiStream.test.tsx` (49 cancellation tests) all pass.
  • Full `packages/cli` suite (3,767 tests) passes.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed an issue where user-initiated cancellations (such as pressing Escape) during active operations could inadvertently trigger unintended operation retries, resulting in unexpected behavior and inconsistent application states.
    • Enhanced recap generation by implementing improved validation to verify sufficient conversation history is available and properly captured before the recap generation process begins.

Automaker and others added 2 commits April 26, 2026 17:24
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>
@mabry1985 mabry1985 merged commit 6d42274 into dev Apr 27, 2026
1 of 3 checks passed
@mabry1985 mabry1985 deleted the fix/recap-skip-on-empty-history branch April 27, 2026 01:19
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 69070df7-93aa-4572-a051-962532c53875

📥 Commits

Reviewing files that changed from the base of the PR and between c11682d and 4d63bea.

📒 Files selected for processing (2)
  • packages/cli/src/ui/hooks/useGeminiStream.ts
  • packages/cli/src/ui/hooks/useRecap.ts

Walkthrough

Two 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

Cohort / File(s) Summary
Tool Completion Cancel Handling
packages/cli/src/ui/hooks/useGeminiStream.ts
Modified handleCompletedTools flow to detect when user-initiated cancel has already set turnCancelledRef.current. Marks terminal tool IDs as submitted and exits early instead of feeding tool responses back to Gemini, preventing abort controller re-creation and turn loop resumption.
Recap Generation Validation
packages/cli/src/ui/hooks/useRecap.ts
Added guard to require at least one prior model-role turn and one prior user-role turn from conversation history before recap generation proceeds. Moves conversation variable computation prior to the validation check.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/recap-skip-on-empty-history

Comment @coderabbitai help to get the list of available commands and usage tips.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant