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

@@ -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,
};
};