Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli/src/ui/components/LoadingIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ describe('<LoadingIndicator />', () => {
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
});

it('should not show cancel and timer when idle even if a phrase exists', async () => {
const { lastFrame, waitUntilReady } = renderWithContext(
<LoadingIndicator currentLoadingPhrase="Retrying..." elapsedTime={5} />,
StreamingState.Idle,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Retrying...');
expect(output).not.toContain('(esc to cancel');
});

it('should render spinner, phrase, and time when streamingState is Responding', async () => {
const { lastFrame, waitUntilReady } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/components/LoadingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({

const cancelAndTimerContent =
showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation
streamingState === StreamingState.Responding
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;

Expand Down
55 changes: 53 additions & 2 deletions packages/cli/src/ui/hooks/useGeminiStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1619,7 +1619,7 @@ describe('useGeminiStream', () => {
});

describe('Retry Handling', () => {
it('should update retryStatus when CoreEvent.RetryAttempt is emitted', async () => {
it('should ignore retryStatus updates when not responding', async () => {
const { result } = renderHookWithDefaults();

const retryPayload = {
Expand All @@ -1633,7 +1633,7 @@ describe('useGeminiStream', () => {
coreEvents.emit(CoreEvent.RetryAttempt, retryPayload);
});

expect(result.current.retryStatus).toEqual(retryPayload);
expect(result.current.retryStatus).toBeNull();
});

it('should reset retryStatus when isResponding becomes false', async () => {
Expand Down Expand Up @@ -1676,6 +1676,57 @@ describe('useGeminiStream', () => {

expect(result.current.retryStatus).toBeNull();
});

it('should ignore late retry events after cancellation', async () => {
const { result } = renderTestHook();
const retryPayload = {
model: 'gemini-2.5-pro',
attempt: 2,
maxAttempts: 3,
delayMs: 1000,
};
const lateRetryPayload = {
model: 'gemini-2.5-pro',
attempt: 3,
maxAttempts: 3,
delayMs: 2000,
};

const mockStream = (async function* () {
yield { type: ServerGeminiEventType.Content, value: 'Part 1' };
await new Promise(() => {}); // Keep stream open
})();
mockSendMessageStream.mockReturnValue(mockStream);

await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
result.current.submitQuery('test query');
});

await waitFor(() => {
expect(result.current.streamingState).toBe(StreamingState.Responding);
});

await act(async () => {
coreEvents.emit(CoreEvent.RetryAttempt, retryPayload);
});

expect(result.current.retryStatus).toEqual(retryPayload);

await act(async () => {
result.current.cancelOngoingRequest();
});

await waitFor(() => {
expect(result.current.retryStatus).toBeNull();
});

await act(async () => {
coreEvents.emit(CoreEvent.RetryAttempt, lateRetryPayload);
});

expect(result.current.retryStatus).toBeNull();
});
});

describe('Slash Command Handling', () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export const useGeminiStream = (
config.getApprovalMode(),
);
const [isResponding, setIsResponding] = useState<boolean>(false);
const isRespondingRef = useRef(isResponding);
const [thought, thoughtRef, setThought] =
useStateAndRef<ThoughtSummary | null>(null);
const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
Expand All @@ -239,8 +240,15 @@ export const useGeminiStream = (
return new GitService(config.getProjectRoot(), storage);
}, [config, storage]);

useEffect(() => {
isRespondingRef.current = isResponding;
}, [isResponding]);

useEffect(() => {
const handleRetryAttempt = (payload: RetryAttemptPayload) => {
if (turnCancelledRef.current || !isRespondingRef.current) {
return;
}
setRetryStatus(payload);
};
coreEvents.on(CoreEvent.RetryAttempt, handleRetryAttempt);
Expand Down Expand Up @@ -626,6 +634,7 @@ export const useGeminiStream = (
return;
}
turnCancelledRef.current = true;
setRetryStatus(null);

// A full cancellation means no tools have produced a final result yet.
// This determines if we show a generic "Request cancelled" message.
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/ui/hooks/useLoadingIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,24 @@ describe('useLoadingIndicator', () => {
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
});

it('should not show retry status phrase when idle', () => {
const retryStatus = {
model: 'gemini-pro',
attempt: 2,
maxAttempts: 3,
delayMs: 1000,
};
const { result } = renderLoadingIndicatorHook(
StreamingState.Idle,
false,
retryStatus,
'all',
'full',
);

expect(result.current.currentLoadingPhrase).toBeUndefined();
});

it('should hide low-verbosity retry status for early retry attempts', () => {
const retryStatus = {
model: 'gemini-pro',
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/ui/hooks/useLoadingIndicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export const useLoadingIndicator = ({
prevStreamingStateRef.current = streamingState;
}, [streamingState, elapsedTimeFromTimer]);

const retryPhrase = retryStatus
const retryPhrase =
streamingState === StreamingState.Responding && retryStatus
? errorVerbosity === 'low'
? retryStatus.attempt >= LOW_VERBOSITY_RETRY_HINT_ATTEMPT_THRESHOLD
? "This is taking a bit longer, we're still on it."
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/utils/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,35 @@ describe('retryWithBackoff', () => {
);
expect(mockFn).toHaveBeenCalledTimes(1);
});

it('should not emit onRetry when abort happens before retry callback', async () => {
const abortController = new AbortController();
const onRetry = vi.fn();
const error = new Error('Server error') as HttpError;
Object.defineProperty(error, 'status', {
configurable: true,
get: () => {
abortController.abort();
return 500;
},
});

const mockFn = vi.fn().mockRejectedValue(error);

const promise = retryWithBackoff(mockFn, {
maxAttempts: 3,
initialDelayMs: 100,
signal: abortController.signal,
onRetry,
});

await expect(promise).rejects.toThrow(
expect.objectContaining({ name: 'AbortError' }),
);
expect(onRetry).not.toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(1);
});

it('should trigger fallback for OAuth personal users on persistent 500 errors', async () => {
const fallbackCallback = vi.fn().mockResolvedValue('gemini-2.5-flash');

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/utils/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ export async function retryWithBackoff<T>(

let attempt = 0;
let currentDelay = initialDelayMs;
const throwIfAborted = () => {
if (signal?.aborted) {
throw createAbortError();
}
};

while (attempt < maxAttempts) {
if (signal?.aborted) {
Expand All @@ -206,6 +211,7 @@ export async function retryWithBackoff<T>(
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
if (onRetry) {
throwIfAborted();
onRetry(attempt, new Error('Invalid content'), delayWithJitter);
}
await delay(delayWithJitter, signal);
Expand Down Expand Up @@ -313,6 +319,7 @@ export async function retryWithBackoff<T>(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${Math.round(delayWithJitter)}ms...`,
);
if (onRetry) {
throwIfAborted();
onRetry(attempt, error, delayWithJitter);
}
await delay(delayWithJitter, signal);
Expand All @@ -326,6 +333,7 @@ export async function retryWithBackoff<T>(
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
if (onRetry) {
throwIfAborted();
onRetry(attempt, error, delayWithJitter);
}
await delay(delayWithJitter, signal);
Expand All @@ -350,6 +358,7 @@ export async function retryWithBackoff<T>(
const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1);
const delayWithJitter = Math.max(0, currentDelay + jitter);
if (onRetry) {
throwIfAborted();
onRetry(attempt, error, delayWithJitter);
}
await delay(delayWithJitter, signal);
Expand Down
Loading