From d951e30cfaf0f0a5035d0723f8ea381a1f785525 Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Thu, 12 Mar 2026 20:43:29 +0800 Subject: [PATCH] fix: clear retry error messages promptly after auto-retry succeeds Previously, when an auto-retry countdown elapsed and the server sent a Retry event (without retryInfo) to signal the actual retry attempt, the error message was not cleared because `pendingRetryCountdownItemRef` was still set. This caused stale error messages to persist in the UI until the user manually initiated a new request. Additionally, when the user pressed Ctrl+Y to retry, the error was committed to history (without hint) instead of being discarded. This was inconsistent with the auto-retry behavior where errors are silently cleared on success. Changes: - Always call clearRetryCountdown() when a Retry event without retryInfo is received, removing the flawed guard condition - Remove error-to-history commit in retryLastPrompt for consistent UX - Add test covering the countdown-elapsed retry scenario Closes #2310 Made-with: Cursor --- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 107 ++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 17 +-- 2 files changed, 110 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index c4a5a6117..e6696ae6b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2244,6 +2244,7 @@ describe('useGeminiStream', () => { it('should show a retry countdown and update pending history over time', async () => { vi.useFakeTimers(); try { + let continueToRetryAttempt: (() => void) | undefined; let resolveStream: (() => void) | undefined; mockSendMessageStream.mockReturnValue( (async function* () { @@ -2256,6 +2257,9 @@ describe('useGeminiStream', () => { delayMs: 3000, }, }; + await new Promise((resolve) => { + continueToRetryAttempt = resolve; + }); yield { type: ServerGeminiEventType.Retry, }; @@ -2330,6 +2334,12 @@ describe('useGeminiStream', () => { '2s', ); + continueToRetryAttempt?.(); + + await act(async () => { + await Promise.resolve(); + }); + resolveStream?.(); await act(async () => { @@ -2347,6 +2357,103 @@ describe('useGeminiStream', () => { } }); + it('should clear retry errors after auto-retry succeeds once the countdown has elapsed', async () => { + vi.useFakeTimers(); + try { + let continueAfterCountdown: (() => void) | undefined; + mockSendMessageStream.mockReturnValue( + (async function* () { + yield { + type: ServerGeminiEventType.Retry, + retryInfo: { + message: '[API Error: Rate limit exceeded]', + attempt: 1, + maxRetries: 3, + delayMs: 1000, + }, + }; + await new Promise((resolve) => { + continueAfterCountdown = resolve; + }); + yield { + type: ServerGeminiEventType.Retry, + }; + yield { + type: ServerGeminiEventType.Text, + value: 'Success after retry', + }; + yield { + type: ServerGeminiEventType.Finished, + value: { reason: 'STOP', usageMetadata: undefined }, + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + act(() => { + void result.current.submitQuery('Trigger retry after countdown'); + }); + + let errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + for (let attempts = 0; attempts < 5 && !errorItem; attempts++) { + await act(async () => { + await Promise.resolve(); + }); + errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + } + expect(errorItem?.hint).toContain('1s'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + const staleErrorBeforeRetryCompletes = + result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ) as { hint?: string } | undefined; + expect(staleErrorBeforeRetryCompletes?.hint).toContain('0s'); + + await act(async () => { + continueAfterCountdown?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const remainingError = result.current.pendingHistoryItems.find( + (item) => item.type === MessageType.ERROR, + ); + expect(remainingError).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + it('should memoize pendingHistoryItems', () => { mockUseReactToolScheduler.mockReturnValue([ [], diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 1d0851501..7614eed00 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1034,7 +1034,8 @@ export const useGeminiStream = ( // Show retry info if available (rate-limit / throttling errors) if (event.retryInfo) { startRetryCountdown(event.retryInfo); - } else if (!pendingRetryCountdownItemRef.current) { + } else { + // The retry attempt is starting now, so any prior retry UI is stale. clearRetryCountdown(); } break; @@ -1075,7 +1076,6 @@ export const useGeminiStream = ( setThought, pendingHistoryItemRef, setPendingHistoryItem, - pendingRetryCountdownItemRef, ], ); @@ -1301,24 +1301,13 @@ export const useGeminiStream = ( return; } - // Commit the error to history (without hint) before clearing - const errorItem = pendingRetryErrorItemRef.current; - if (errorItem) { - addItem({ type: errorItem.type, text: errorItem.text }, Date.now()); - } clearRetryCountdown(); await submitQuery(lastPrompt, { isContinuation: false, skipPreparation: true, }); - }, [ - streamingState, - addItem, - clearRetryCountdown, - submitQuery, - pendingRetryErrorItemRef, - ]); + }, [streamingState, addItem, clearRetryCountdown, submitQuery]); const handleApprovalModeChange = useCallback( async (newApprovalMode: ApprovalMode) => {