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
This commit is contained in:
yiliang114 2026-03-12 20:43:29 +08:00
parent e181cfc097
commit d951e30cfa
2 changed files with 110 additions and 14 deletions

View file

@ -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<void>((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<void>((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([
[],

View file

@ -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) => {