Skip to content

Commit da4928f

Browse files
mabry1985Automakerclaude
authored
fix(cli): unstick streamingState after Esc-cancel + close leaked turn span (#145)
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: Automaker <automaker@localhost> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d697ddf commit da4928f

2 files changed

Lines changed: 168 additions & 10 deletions

File tree

packages/cli/src/ui/hooks/useGeminiStream.ts

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
ToolConfirmationOutcome,
3939
logApiCancel,
4040
ApiCancelEvent,
41+
endTurnSpan,
4142
isSupportedImageMimeType,
4243
getUnsupportedImageFormatWarning,
4344
hasFileEdits,
@@ -262,13 +263,27 @@ export const useGeminiStream = (
262263
[],
263264
);
264265

265-
const [toolCalls, scheduleToolCalls, markToolsAsSubmitted] =
266-
useReactToolScheduler(
267-
stableOnComplete,
268-
config,
269-
getPreferredEditor,
270-
onEditorClose,
271-
);
266+
const [
267+
toolCalls,
268+
scheduleToolCalls,
269+
markToolsAsSubmitted,
270+
forceCancelStaleToolCalls,
271+
] = useReactToolScheduler(
272+
stableOnComplete,
273+
config,
274+
getPreferredEditor,
275+
onEditorClose,
276+
);
277+
278+
// Stable refs so cancelOngoingRequest can read the current toolCalls
279+
// and call forceCancelStaleToolCalls without itself depending on those
280+
// values (which would invalidate the callback every render).
281+
const toolCallsRef = useRef(toolCalls);
282+
toolCallsRef.current = toolCalls;
283+
const markToolsAsSubmittedRef = useRef(markToolsAsSubmitted);
284+
markToolsAsSubmittedRef.current = markToolsAsSubmitted;
285+
const forceCancelStaleToolCallsRef = useRef(forceCancelStaleToolCalls);
286+
forceCancelStaleToolCallsRef.current = forceCancelStaleToolCalls;
272287

273288
const pendingToolCallGroupDisplay = useMemo(
274289
() =>
@@ -510,6 +525,40 @@ export const useGeminiStream = (
510525
onCancelSubmit();
511526
setIsResponding(false);
512527
setShellInputFocused(false);
528+
529+
// Close any leaked OTel turn span so the recap / prompt-suggestion
530+
// LLM calls that fire on streamingState=Idle don't get nested under
531+
// a still-open turn span (Langfuse otherwise reports the turn as
532+
// running until the next prompt opens a new span).
533+
endTurnSpan('ok');
534+
535+
// Immediately flip responseSubmittedToGemini=true on every current
536+
// toolCall. Handles the common case where a tool finished but the
537+
// submitted flag wasn't flipped (the in-code comment at the top of
538+
// this hook documents the same class of bug for a different cause).
539+
const currentCallIds = toolCallsRef.current.map((tc) => tc.request.callId);
540+
if (currentCallIds.length > 0) {
541+
markToolsAsSubmittedRef.current(currentCallIds);
542+
}
543+
544+
// Schedule a grace-window force-clear for any tool that ignored the
545+
// abort signal. Without this, a runaway subagent (or any tool that
546+
// doesn't honor signal.aborted) leaves the toolCall in a non-terminal
547+
// state, which keeps streamingState=Responding and silently drops
548+
// every subsequent user submission via submitQuery's guard at line
549+
// 1305. Three seconds is generous — well-behaved tools clean up in ms.
550+
setTimeout(() => {
551+
const cleared = forceCancelStaleToolCallsRef.current();
552+
if (cleared > 0) {
553+
addItem(
554+
{
555+
type: MessageType.WARNING,
556+
text: `Force-cleared ${cleared} stuck tool call(s) after cancel grace window. The underlying process(es) may still be running in the background.`,
557+
},
558+
Date.now(),
559+
);
560+
}
561+
}, 3000);
513562
}, [
514563
streamingState,
515564
addItem,
@@ -1310,6 +1359,31 @@ export const useGeminiStream = (
13101359
) {
13111360
// Release the guard — we're not actually going to run
13121361
if (generation !== null) queryGuardRef.current.end(generation);
1362+
// Surface the dropped submission so the user knows their input was
1363+
// not sent. Previously this was a silent `return`, which made
1364+
// protoCLI feel hung when a tool refused to honor cancel — the
1365+
// user types and gets nothing back forever. With the cancel
1366+
// handler's grace-window force-clear (~3s), this state is now
1367+
// self-resolving; the message just tells them to wait or retry.
1368+
const stateLabel =
1369+
streamingState === StreamingState.Responding
1370+
? 'still responding'
1371+
: streamingState === StreamingState.WaitingForConfirmation
1372+
? 'awaiting tool confirmation'
1373+
: 'backgrounded';
1374+
addItem(
1375+
{
1376+
type: MessageType.WARNING,
1377+
text: `Input not sent — previous turn is ${stateLabel}. ${
1378+
streamingState === StreamingState.WaitingForConfirmation
1379+
? 'Approve or reject the pending tool call first.'
1380+
: streamingState === StreamingState.Backgrounded
1381+
? 'Wait for the backgrounded turn to finish or press Esc to cancel.'
1382+
: 'Press Esc to cancel and try again (stuck tools auto-clear after 3s).'
1383+
}`,
1384+
},
1385+
Date.now(),
1386+
);
13131387
return;
13141388
}
13151389

packages/cli/src/ui/hooks/useReactToolScheduler.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
CoreToolScheduler,
2525
createDebugLogger,
2626
} from '@qwen-code/qwen-code-core';
27-
import { useCallback, useState, useMemo } from 'react';
27+
import { useCallback, useState, useMemo, useRef } from 'react';
2828
import type {
2929
HistoryItemToolGroup,
3030
IndividualToolCallDisplay,
@@ -38,6 +38,16 @@ export type ScheduleFn = (
3838
signal: AbortSignal,
3939
) => void;
4040
export type MarkToolsAsSubmittedFn = (callIds: string[]) => void;
41+
/**
42+
* Forcibly transition any non-terminal tool calls to a synthetic
43+
* `cancelled` state with `responseSubmittedToGemini=true`. Used by the
44+
* cancel handler to unblock `streamingState` when a tool ignores its
45+
* AbortSignal — the underlying process may keep running, but the UI
46+
* stops being held hostage by it. Returns the number of calls that
47+
* were force-cancelled (so callers can decide whether to surface a
48+
* notice).
49+
*/
50+
export type ForceCancelStaleToolCallsFn = () => number;
4151

4252
export type TrackedScheduledToolCall = ScheduledToolCall & {
4353
responseSubmittedToGemini?: boolean;
@@ -72,7 +82,12 @@ export function useReactToolScheduler(
7282
config: Config,
7383
getPreferredEditor: () => EditorType | undefined,
7484
onEditorClose: () => void,
75-
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
85+
): [
86+
TrackedToolCall[],
87+
ScheduleFn,
88+
MarkToolsAsSubmittedFn,
89+
ForceCancelStaleToolCallsFn,
90+
] {
7691
const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
7792
TrackedToolCall[]
7893
>([]);
@@ -179,7 +194,76 @@ export function useReactToolScheduler(
179194
[],
180195
);
181196

182-
return [toolCallsForDisplay, schedule, markToolsAsSubmitted];
197+
// Track how many calls were transitioned by the most recent
198+
// forceCancelStaleToolCalls invocation. The setter callback runs inside
199+
// setToolCallsForDisplay's reducer, so we need a side-channel ref to
200+
// return a synchronous count to the caller.
201+
const lastForceCancelledCountRef = useRef(0);
202+
203+
const forceCancelStaleToolCalls: ForceCancelStaleToolCallsFn =
204+
useCallback(() => {
205+
lastForceCancelledCountRef.current = 0;
206+
setToolCallsForDisplay((prevCalls) => {
207+
let changed = 0;
208+
const next = prevCalls.map((tc): TrackedToolCall => {
209+
// Already terminal: just guarantee the submitted flag so
210+
// streamingState clears even if handleCompletedTools never ran.
211+
if (
212+
tc.status === 'success' ||
213+
tc.status === 'error' ||
214+
tc.status === 'cancelled'
215+
) {
216+
if (tc.responseSubmittedToGemini) return tc;
217+
changed++;
218+
return { ...tc, responseSubmittedToGemini: true };
219+
}
220+
221+
// Non-terminal: synthesize a cancelled response. The underlying
222+
// process may still be running — the UI stops waiting on it.
223+
changed++;
224+
const cancelled: TrackedCancelledToolCall = {
225+
request: tc.request,
226+
tool: 'tool' in tc ? tc.tool : undefined!,
227+
invocation: 'invocation' in tc ? tc.invocation : undefined!,
228+
status: 'cancelled',
229+
response: {
230+
callId: tc.request.callId,
231+
error: undefined,
232+
errorType: undefined,
233+
responseParts: [
234+
{
235+
functionResponse: {
236+
id: tc.request.callId,
237+
name: tc.request.name,
238+
response: {
239+
error:
240+
'User cancelled. Tool was force-cleared after the abort signal did not stop it within the grace window; the underlying process may still be running.',
241+
},
242+
},
243+
},
244+
],
245+
resultDisplay:
246+
'Force-cancelled (tool did not honor AbortSignal in time).',
247+
contentLength: undefined,
248+
},
249+
durationMs: undefined,
250+
outcome: tc.outcome,
251+
responseSubmittedToGemini: true,
252+
};
253+
return cancelled;
254+
});
255+
lastForceCancelledCountRef.current = changed;
256+
return changed > 0 ? next : prevCalls;
257+
});
258+
return lastForceCancelledCountRef.current;
259+
}, []);
260+
261+
return [
262+
toolCallsForDisplay,
263+
schedule,
264+
markToolsAsSubmitted,
265+
forceCancelStaleToolCalls,
266+
];
183267
}
184268

185269
/**

0 commit comments

Comments
 (0)