From b6b15e45e8a67e4e792129e48074607fb76e0f1e Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 14 Jun 2026 06:36:48 +0800 Subject: [PATCH] fix(cli): drop tool calls after cancellation (#5020) --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 71 +++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 3 +- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index ab98e093ad..7b20d6b8c9 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -4915,6 +4915,77 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Idle); }); + it('should drop queued tool calls when user cancels the turn', async () => { + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { + callId: 'call_cancelled', + name: 'write_file', + args: { path: 'cancelled.txt' }, + }, + }; + yield { type: ServerGeminiEventType.UserCancelled }; + })(), + ); + + const { result } = renderTestHook(); + + await act(async () => { + await result.current.submitQuery('cancel before tool dispatch'); + }); + + expect(mockScheduleToolCalls).not.toHaveBeenCalled(); + }); + + it('should not dispatch queued tool calls after the request is aborted', async () => { + let resolveStream!: () => void; + let toolCallQueued!: () => void; + + const streamCanFinish = new Promise((resolve) => { + resolveStream = resolve; + }); + const toolCallWasQueued = new Promise((resolve) => { + toolCallQueued = resolve; + }); + + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.ToolCallRequest, + value: { + callId: 'call_aborted', + name: 'write_file', + args: { path: 'aborted.txt' }, + }, + }; + toolCallQueued(); + await streamCanFinish; + })(), + ); + + const { result } = renderTestHook(); + + let submitPromise!: Promise; + await act(async () => { + submitPromise = result.current.submitQuery( + 'abort before tool dispatch', + ); + }); + + await toolCallWasQueued; + + act(() => { + result.current.cancelOngoingRequest(); + }); + + resolveStream(); + await submitPromise; + + expect(mockScheduleToolCalls).not.toHaveBeenCalled(); + }); + it('should reset thought to null when there is an error', async () => { // Mock a stream that yields a thought then encounters an error mockSendMessageStream.mockReturnValue( diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 218aaef7b3..26bdf18396 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1535,6 +1535,7 @@ export const useGeminiStream = ( break; case ServerGeminiEventType.UserCancelled: flushBufferedStreamEvents(); + toolCallRequests.length = 0; handleUserCancelledEvent(userMessageTimestamp); break; case ServerGeminiEventType.Error: @@ -1663,7 +1664,7 @@ export const useGeminiStream = ( flushBufferedStreamEventsRef.current.delete(flushBufferedStreamEvents); } dualOutput?.finalizeAssistantMessage(); - if (toolCallRequests.length > 0) { + if (toolCallRequests.length > 0 && !signal.aborted) { scheduleToolCalls(toolCallRequests, signal); } return StreamProcessingStatus.Completed;