summaryrefslogtreecommitdiff
path: root/packages/core/src/services/loopDetectionService.test.ts
diff options
context:
space:
mode:
authorSandy Tao <[email protected]>2025-07-14 20:25:16 -0700
committerGitHub <[email protected]>2025-07-15 03:25:16 +0000
commit734da8b9d24ab7e2cfcfe80e43f061734b7ae6ba (patch)
tree331af366a279eb8fb4cbdf6d008e357a72ebe3cf /packages/core/src/services/loopDetectionService.test.ts
parent7ffe8038efaa5bf263a2a933819bcd4badd37dc2 (diff)
Introduce loop detection service that breaks simple loop (#3919)
Co-authored-by: Scott Densmore <[email protected]> Co-authored-by: N. Taylor Mullen <[email protected]>
Diffstat (limited to 'packages/core/src/services/loopDetectionService.test.ts')
-rw-r--r--packages/core/src/services/loopDetectionService.test.ts294
1 files changed, 294 insertions, 0 deletions
diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts
new file mode 100644
index 00000000..915bedc0
--- /dev/null
+++ b/packages/core/src/services/loopDetectionService.test.ts
@@ -0,0 +1,294 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { LoopDetectionService } from './loopDetectionService.js';
+import {
+ GeminiEventType,
+ ServerGeminiContentEvent,
+ ServerGeminiToolCallRequestEvent,
+} from '../core/turn.js';
+import { ServerGeminiStreamEvent } from '../core/turn.js';
+
+const TOOL_CALL_LOOP_THRESHOLD = 5;
+const CONTENT_LOOP_THRESHOLD = 10;
+
+describe('LoopDetectionService', () => {
+ let service: LoopDetectionService;
+
+ beforeEach(() => {
+ service = new LoopDetectionService();
+ });
+
+ const createToolCallRequestEvent = (
+ name: string,
+ args: Record<string, unknown>,
+ ): ServerGeminiToolCallRequestEvent => ({
+ type: GeminiEventType.ToolCallRequest,
+ value: {
+ name,
+ args,
+ callId: 'test-id',
+ isClientInitiated: false,
+ prompt_id: 'test-prompt-id',
+ },
+ });
+
+ const createContentEvent = (content: string): ServerGeminiContentEvent => ({
+ type: GeminiEventType.Content,
+ value: content,
+ });
+
+ 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 = 0; i < TOOL_CALL_LOOP_THRESHOLD - 1; i++) {
+ expect(service.addAndCheck(event)).toBe(false);
+ }
+ });
+
+ 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 - 1; i++) {
+ service.addAndCheck(event);
+ }
+ expect(service.addAndCheck(event)).toBe(true);
+ });
+
+ 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(true);
+ });
+
+ 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(false);
+ expect(service.addAndCheck(event2)).toBe(false);
+ expect(service.addAndCheck(event3)).toBe(false);
+ }
+ });
+ });
+
+ describe('Content Loop Detection', () => {
+ it(`should not detect a loop for fewer than CONTENT_LOOP_THRESHOLD identical content strings`, () => {
+ const event = createContentEvent('This is a test sentence.');
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
+ expect(service.addAndCheck(event)).toBe(false);
+ }
+ });
+
+ it(`should detect a loop on the CONTENT_LOOP_THRESHOLD-th identical content string`, () => {
+ const event = createContentEvent('This is a test sentence.');
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
+ service.addAndCheck(event);
+ }
+ expect(service.addAndCheck(event)).toBe(true);
+ });
+
+ it('should not detect a loop for different content strings', () => {
+ const event1 = createContentEvent('Sentence A');
+ const event2 = createContentEvent('Sentence B');
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 2; i++) {
+ expect(service.addAndCheck(event1)).toBe(false);
+ expect(service.addAndCheck(event2)).toBe(false);
+ }
+ });
+ });
+
+ describe('Sentence Extraction and Punctuation', () => {
+ it('should not check for loops when content has no sentence-ending punctuation', () => {
+ const eventNoPunct = createContentEvent('This has no punctuation');
+ expect(service.addAndCheck(eventNoPunct)).toBe(false);
+
+ const eventWithPunct = createContentEvent('This has punctuation!');
+ expect(service.addAndCheck(eventWithPunct)).toBe(false);
+ });
+
+ it('should not treat function calls or method calls as sentence endings', () => {
+ // These should not trigger sentence detection, so repeating them many times should never cause a loop
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) {
+ expect(service.addAndCheck(createContentEvent('console.log()'))).toBe(
+ false,
+ );
+ }
+
+ service.reset();
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) {
+ expect(service.addAndCheck(createContentEvent('obj.method()'))).toBe(
+ false,
+ );
+ }
+
+ service.reset();
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) {
+ expect(
+ service.addAndCheck(createContentEvent('arr.filter().map()')),
+ ).toBe(false);
+ }
+
+ service.reset();
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) {
+ expect(
+ service.addAndCheck(
+ createContentEvent('if (condition) { return true; }'),
+ ),
+ ).toBe(false);
+ }
+ });
+
+ it('should correctly identify actual sentence endings and trigger loop detection', () => {
+ // These should trigger sentence detection, so repeating them should eventually cause a loop
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
+ expect(
+ service.addAndCheck(createContentEvent('This is a sentence.')),
+ ).toBe(false);
+ }
+ expect(
+ service.addAndCheck(createContentEvent('This is a sentence.')),
+ ).toBe(true);
+
+ service.reset();
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
+ expect(
+ service.addAndCheck(createContentEvent('Is this a question? ')),
+ ).toBe(false);
+ }
+ expect(
+ service.addAndCheck(createContentEvent('Is this a question? ')),
+ ).toBe(true);
+
+ service.reset();
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
+ expect(
+ service.addAndCheck(createContentEvent('What excitement!\n')),
+ ).toBe(false);
+ }
+ expect(
+ service.addAndCheck(createContentEvent('What excitement!\n')),
+ ).toBe(true);
+ });
+
+ it('should handle content with mixed punctuation', () => {
+ service.addAndCheck(createContentEvent('Question?'));
+ service.addAndCheck(createContentEvent('Exclamation!'));
+ service.addAndCheck(createContentEvent('Period.'));
+
+ // Repeat one of them multiple times
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
+ service.addAndCheck(createContentEvent('Period.'));
+ }
+ expect(service.addAndCheck(createContentEvent('Period.'))).toBe(true);
+ });
+
+ it('should handle empty sentences after trimming', () => {
+ service.addAndCheck(createContentEvent(' .'));
+ expect(service.addAndCheck(createContentEvent('Normal sentence.'))).toBe(
+ false,
+ );
+ });
+
+ it('should require at least two sentences for loop detection', () => {
+ const event = createContentEvent('Only one sentence.');
+ expect(service.addAndCheck(event)).toBe(false);
+
+ // Even repeating the same single sentence shouldn't trigger detection
+ for (let i = 0; i < 5; i++) {
+ expect(service.addAndCheck(event)).toBe(false);
+ }
+ });
+ });
+
+ describe('Performance Optimizations', () => {
+ it('should cache sentence extraction and only re-extract when content grows significantly', () => {
+ // Add initial content
+ service.addAndCheck(createContentEvent('First sentence.'));
+ service.addAndCheck(createContentEvent('Second sentence.'));
+
+ // Add small amounts of content (shouldn't trigger re-extraction)
+ for (let i = 0; i < 10; i++) {
+ service.addAndCheck(createContentEvent('X'));
+ }
+ service.addAndCheck(createContentEvent('.'));
+
+ // Should still work correctly
+ expect(service.addAndCheck(createContentEvent('Test.'))).toBe(false);
+ });
+
+ it('should re-extract sentences when content grows by more than 100 characters', () => {
+ service.addAndCheck(createContentEvent('Initial sentence.'));
+
+ // Add enough content to trigger re-extraction
+ const longContent = 'X'.repeat(101);
+ service.addAndCheck(createContentEvent(longContent + '.'));
+
+ // Should work correctly after re-extraction
+ expect(service.addAndCheck(createContentEvent('Test.'))).toBe(false);
+ });
+
+ it('should use indexOf for efficient counting instead of regex', () => {
+ const repeatedSentence = 'This is a repeated sentence.';
+
+ // Build up content with the sentence repeated
+ for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
+ service.addAndCheck(createContentEvent(repeatedSentence));
+ }
+
+ // The threshold should be reached
+ expect(service.addAndCheck(createContentEvent(repeatedSentence))).toBe(
+ true,
+ );
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty content', () => {
+ const event = createContentEvent('');
+ expect(service.addAndCheck(event)).toBe(false);
+ });
+ });
+
+ describe('Reset Functionality', () => {
+ it('tool call should reset content count', () => {
+ const contentEvent = createContentEvent('Some content.');
+ const toolEvent = createToolCallRequestEvent('testTool', {
+ param: 'value',
+ });
+ for (let i = 0; i < 9; i++) {
+ service.addAndCheck(contentEvent);
+ }
+
+ service.addAndCheck(toolEvent);
+
+ // Should start fresh
+ expect(service.addAndCheck(createContentEvent('Fresh content.'))).toBe(
+ false,
+ );
+ });
+ });
+
+ describe('General Behavior', () => {
+ it('should return false for unhandled event types', () => {
+ const otherEvent = {
+ type: 'unhandled_event',
+ } as unknown as ServerGeminiStreamEvent;
+ expect(service.addAndCheck(otherEvent)).toBe(false);
+ expect(service.addAndCheck(otherEvent)).toBe(false);
+ });
+ });
+});