mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-13 07:30:52 -07:00
feat(ui): Add confirmation dialog for disabling loop detection for current session (#8231)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
88
packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
Normal file
88
packages/cli/src/ui/components/LoopDetectionConfirmation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -284,3 +284,7 @@ export interface ConfirmationRequest {
|
||||
prompt: ReactNode;
|
||||
onConfirm: (confirm: boolean) => void;
|
||||
}
|
||||
|
||||
export interface LoopDetectionConfirmationRequest {
|
||||
onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user