feat(ui): Add confirmation dialog for disabling loop detection for current session (#8231)

This commit is contained in:
Sandy Tao
2025-09-10 22:20:13 -07:00
committed by GitHub
parent 5b2176770e
commit 78744cfbca
12 changed files with 498 additions and 39 deletions

View File

@@ -505,6 +505,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
pendingHistoryItems: pendingGeminiHistoryItems,
thought,
cancelOngoingRequest,
loopDetectionConfirmationRequest,
} = useGeminiStream(
config.getGeminiClient(),
historyManager.history,
@@ -896,35 +897,20 @@ Logging in with Google... Please restart Gemini CLI to continue.
const nightly = props.version.includes('nightly');
const dialogsVisible = useMemo(
() =>
showWorkspaceMigrationDialog ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
isAuthenticating ||
isAuthDialogOpen ||
isEditorDialogOpen ||
showPrivacyNotice ||
!!proQuotaRequest,
[
showWorkspaceMigrationDialog,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
shellConfirmationRequest,
confirmationRequest,
isThemeDialogOpen,
isSettingsDialogOpen,
isAuthenticating,
isAuthDialogOpen,
isEditorDialogOpen,
showPrivacyNotice,
proQuotaRequest,
],
);
const dialogsVisible =
showWorkspaceMigrationDialog ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
isAuthenticating ||
isAuthDialogOpen ||
isEditorDialogOpen ||
showPrivacyNotice ||
!!proQuotaRequest;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
@@ -952,6 +938,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
commandContext,
shellConfirmationRequest,
confirmationRequest,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,
@@ -1024,6 +1011,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
commandContext,
shellConfirmationRequest,
confirmationRequest,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,
initError,

View File

@@ -6,6 +6,7 @@
import { Box, Text } from 'ink';
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
@@ -83,6 +84,13 @@ export const DialogManager = () => {
<ShellConfirmationDialog request={uiState.shellConfirmationRequest} />
);
}
if (uiState.loopDetectionConfirmationRequest) {
return (
<LoopDetectionConfirmation
onComplete={uiState.loopDetectionConfirmationRequest.onComplete}
/>
);
}
if (uiState.confirmationRequest) {
return (
<Box flexDirection="column">

View File

@@ -0,0 +1,34 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
describe('LoopDetectionConfirmation', () => {
const onComplete = vi.fn();
it('renders correctly', () => {
const { lastFrame } = renderWithProviders(
<LoopDetectionConfirmation onComplete={onComplete} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('contains the expected options', () => {
const { lastFrame } = renderWithProviders(
<LoopDetectionConfirmation onComplete={onComplete} />,
);
const output = lastFrame()!.toString();
expect(output).toContain('A potential loop was detected');
expect(output).toContain('Keep loop detection enabled (esc)');
expect(output).toContain('Disable loop detection for this session');
expect(output).toContain(
'This can happen due to repetitive tool calls or other model behavior',
);
});
});

View File

@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
export type LoopDetectionConfirmationResult = {
userSelection: 'disable' | 'keep';
};
interface LoopDetectionConfirmationProps {
onComplete: (result: LoopDetectionConfirmationResult) => void;
}
export function LoopDetectionConfirmation({
onComplete,
}: LoopDetectionConfirmationProps) {
useKeypress(
(key) => {
if (key.name === 'escape') {
onComplete({
userSelection: 'keep',
});
}
},
{ isActive: true },
);
const OPTIONS: Array<RadioSelectItem<LoopDetectionConfirmationResult>> = [
{
label: 'Keep loop detection enabled (esc)',
value: {
userSelection: 'keep',
},
},
{
label: 'Disable loop detection for this session',
value: {
userSelection: 'disable',
},
},
];
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
width="100%"
marginLeft={1}
>
<Box paddingX={1} paddingY={0} flexDirection="column">
<Box minHeight={1}>
<Box minWidth={3}>
<Text color={theme.status.warning} aria-label="Loop detected:">
?
</Text>
</Box>
<Box>
<Text wrap="truncate-end">
<Text color={theme.text.primary} bold>
A potential loop was detected
</Text>{' '}
</Text>
</Box>
</Box>
<Box width="100%" marginTop={1}>
<Box flexDirection="column">
<Text color={theme.text.secondary}>
This can happen due to repetitive tool calls or other model
behavior. Do you want to keep loop detection enabled or disable it
for this session?
</Text>
<Box marginTop={1}>
<RadioButtonSelect items={OPTIONS} onSelect={onComplete} />
</Box>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LoopDetectionConfirmation > renders correctly 1`] = `
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ? A potential loop was detected │
│ │
│ This can happen due to repetitive tool calls or other model behavior. Do you want to keep loop │
│ detection enabled or disable it for this session? │
│ │
│ ● 1. Keep loop detection enabled (esc) │
│ 2. Disable loop detection for this session │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -11,6 +11,7 @@ import type {
ConsoleMessageItem,
ShellConfirmationRequest,
ConfirmationRequest,
LoopDetectionConfirmationRequest,
HistoryItemWithoutId,
StreamingState,
} from '../types.js';
@@ -53,6 +54,7 @@ export interface UIState {
commandContext: CommandContext;
shellConfirmationRequest: ShellConfirmationRequest | null;
confirmationRequest: ConfirmationRequest | null;
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;
initError: string | null;

View File

@@ -1789,4 +1789,262 @@ describe('useGeminiStream', () => {
);
});
});
describe('Loop Detection Confirmation', () => {
beforeEach(() => {
// Add mock for getLoopDetectionService to the config
const mockLoopDetectionService = {
disableForSession: vi.fn(),
};
mockConfig.getGeminiClient = vi.fn().mockReturnValue({
...new MockedGeminiClientClass(mockConfig),
getLoopDetectionService: () => mockLoopDetectionService,
});
});
it('should set loopDetectionConfirmationRequest when LoopDetected event is received', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Some content',
};
yield {
type: ServerGeminiEventType.LoopDetected,
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
await waitFor(() => {
expect(result.current.loopDetectionConfirmationRequest).not.toBeNull();
expect(
typeof result.current.loopDetectionConfirmationRequest?.onComplete,
).toBe('function');
});
});
it('should disable loop detection and show message when user selects "disable"', async () => {
const mockLoopDetectionService = {
disableForSession: vi.fn(),
};
const mockClient = {
...new MockedGeminiClientClass(mockConfig),
getLoopDetectionService: () => mockLoopDetectionService,
};
mockConfig.getGeminiClient = vi.fn().mockReturnValue(mockClient);
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.LoopDetected,
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
// Wait for confirmation request to be set
await waitFor(() => {
expect(result.current.loopDetectionConfirmationRequest).not.toBeNull();
});
// Simulate user selecting "disable"
await act(async () => {
result.current.loopDetectionConfirmationRequest?.onComplete({
userSelection: 'disable',
});
});
// Verify loop detection was disabled
expect(mockLoopDetectionService.disableForSession).toHaveBeenCalledTimes(
1,
);
// Verify confirmation request was cleared
expect(result.current.loopDetectionConfirmationRequest).toBeNull();
// Verify appropriate message was added
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'Loop detection has been disabled for this session. Please try your request again.',
},
expect.any(Number),
);
});
it('should keep loop detection enabled and show message when user selects "keep"', async () => {
const mockLoopDetectionService = {
disableForSession: vi.fn(),
};
const mockClient = {
...new MockedGeminiClientClass(mockConfig),
getLoopDetectionService: () => mockLoopDetectionService,
};
mockConfig.getGeminiClient = vi.fn().mockReturnValue(mockClient);
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.LoopDetected,
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
// Wait for confirmation request to be set
await waitFor(() => {
expect(result.current.loopDetectionConfirmationRequest).not.toBeNull();
});
// Simulate user selecting "keep"
await act(async () => {
result.current.loopDetectionConfirmationRequest?.onComplete({
userSelection: 'keep',
});
});
// Verify loop detection was NOT disabled
expect(mockLoopDetectionService.disableForSession).not.toHaveBeenCalled();
// Verify confirmation request was cleared
expect(result.current.loopDetectionConfirmationRequest).toBeNull();
// Verify appropriate message was added
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.',
},
expect.any(Number),
);
});
it('should handle multiple loop detection events properly', async () => {
const { result } = renderTestHook();
// First loop detection - set up fresh mock for first call
mockSendMessageStream.mockReturnValueOnce(
(async function* () {
yield {
type: ServerGeminiEventType.LoopDetected,
};
})(),
);
// First loop detection
await act(async () => {
await result.current.submitQuery('first query');
});
await waitFor(() => {
expect(result.current.loopDetectionConfirmationRequest).not.toBeNull();
});
// Simulate user selecting "keep" for first request
await act(async () => {
result.current.loopDetectionConfirmationRequest?.onComplete({
userSelection: 'keep',
});
});
expect(result.current.loopDetectionConfirmationRequest).toBeNull();
// Verify first message was added
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.',
},
expect.any(Number),
);
// Second loop detection - set up fresh mock for second call
mockSendMessageStream.mockReturnValueOnce(
(async function* () {
yield {
type: ServerGeminiEventType.LoopDetected,
};
})(),
);
// Second loop detection
await act(async () => {
await result.current.submitQuery('second query');
});
await waitFor(() => {
expect(result.current.loopDetectionConfirmationRequest).not.toBeNull();
});
// Simulate user selecting "disable" for second request
await act(async () => {
result.current.loopDetectionConfirmationRequest?.onComplete({
userSelection: 'disable',
});
});
expect(result.current.loopDetectionConfirmationRequest).toBeNull();
// Verify second message was added
expect(mockAddItem).toHaveBeenCalledWith(
{
type: 'info',
text: 'Loop detection has been disabled for this session. Please try your request again.',
},
expect.any(Number),
);
});
it('should process LoopDetected event after moving pending history to history', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Some response content',
};
yield {
type: ServerGeminiEventType.LoopDetected,
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query');
});
// Verify that the content was added to history before the loop detection dialog
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'gemini',
text: 'Some response content',
}),
expect.any(Number),
);
});
// Then verify loop detection confirmation request was set
await waitFor(() => {
expect(result.current.loopDetectionConfirmationRequest).not.toBeNull();
});
});
});
});

View File

@@ -153,6 +153,12 @@ export const useGeminiStream = (
);
const loopDetectedRef = useRef(false);
const [
loopDetectionConfirmationRequest,
setLoopDetectionConfirmationRequest,
] = useState<{
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
} | null>(null);
const onExec = useCallback(async (done: Promise<void>) => {
setIsResponding(true);
@@ -588,15 +594,38 @@ export const useGeminiStream = (
[addItem, config],
);
const handleLoopDetectionConfirmation = useCallback(
(result: { userSelection: 'disable' | 'keep' }) => {
setLoopDetectionConfirmationRequest(null);
if (result.userSelection === 'disable') {
config.getGeminiClient().getLoopDetectionService().disableForSession();
addItem(
{
type: 'info',
text: `Loop detection has been disabled for this session. Please try your request again.`,
},
Date.now(),
);
} else {
addItem(
{
type: 'info',
text: `A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.`,
},
Date.now(),
);
}
},
[config, addItem],
);
const handleLoopDetectedEvent = useCallback(() => {
addItem(
{
type: 'info',
text: `A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.`,
},
Date.now(),
);
}, [addItem]);
// Show the confirmation dialog to choose whether to disable loop detection
setLoopDetectionConfirmationRequest({
onComplete: handleLoopDetectionConfirmation,
});
}, [handleLoopDetectionConfirmation]);
const processGeminiStreamEvents = useCallback(
async (
@@ -1045,5 +1074,6 @@ export const useGeminiStream = (
pendingHistoryItems,
thought,
cancelOngoingRequest,
loopDetectionConfirmationRequest,
};
};

View File

@@ -284,3 +284,7 @@ export interface ConfirmationRequest {
prompt: ReactNode;
onConfirm: (confirm: boolean) => void;
}
export interface LoopDetectionConfirmationRequest {
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
}