Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/cli/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,22 @@ In addition to a project settings file, a project's `.qwen` directory can contai
"showLineNumbers": false
```

- **`skipNextSpeakerCheck`** (boolean):
- **Description:** Skips the next speaker check after text responses. When enabled, the system bypasses analyzing whether the AI should continue speaking.
- **Default:** `false`
- **Example:**
```json
"skipNextSpeakerCheck": true
```

- **`skipLoopDetection`** (boolean):
- **Description:** Disables all loop detection checks (streaming and LLM-based). Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
- **Default:** `false`
- **Example:**
```json
"skipLoopDetection": true
```

### Example `settings.json`:

```json
Expand Down Expand Up @@ -338,6 +354,8 @@ In addition to a project settings file, a project's `.qwen` directory can contai
"usageStatisticsEnabled": true,
"hideTips": false,
"hideBanner": false,
"skipNextSpeakerCheck": false,
"skipLoopDetection": false,
"maxSessionTurns": 10,
"summarizeToolOutput": {
"run_shell_command": {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ export async function loadCliConfig(
trustedFolder,
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
skipNextSpeakerCheck: settings.skipNextSpeakerCheck,
skipLoopDetection: settings.skipLoopDetection ?? false,
});
}

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,15 @@ export const SETTINGS_SCHEMA = {
description: 'Skip the next speaker check.',
showInDialog: true,
},
skipLoopDetection: {
type: 'boolean',
label: 'Skip Loop Detection',
category: 'General',
requiresRestart: false,
default: false,
description: 'Disable all loop detection checks (streaming and LLM).',
showInDialog: true,
},
enableWelcomeBack: {
type: 'boolean',
label: 'Enable Welcome Back',
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ export interface ConfigParameters {
trustedFolder?: boolean;
shouldUseNodePtyShell?: boolean;
skipNextSpeakerCheck?: boolean;
skipLoopDetection?: boolean;
}

export class Config {
Expand Down Expand Up @@ -318,6 +319,7 @@ export class Config {
private readonly trustedFolder: boolean | undefined;
private readonly shouldUseNodePtyShell: boolean;
private readonly skipNextSpeakerCheck: boolean;
private readonly skipLoopDetection: boolean;
private initialized: boolean = false;

constructor(params: ConfigParameters) {
Expand Down Expand Up @@ -399,6 +401,7 @@ export class Config {
this.trustedFolder = params.trustedFolder;
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
this.skipLoopDetection = params.skipLoopDetection ?? false;

// Web search
this.tavilyApiKey = params.tavilyApiKey;
Expand Down Expand Up @@ -861,6 +864,10 @@ export class Config {
return this.skipNextSpeakerCheck;
}

getSkipLoopDetection(): boolean {
return this.skipLoopDetection;
}

async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir);
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/core/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ describe('Gemini Client (client.ts)', () => {
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
getChatCompression: vi.fn().mockReturnValue(undefined),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
getSkipLoopDetection: vi.fn().mockReturnValue(false),
};
const MockedConfig = vi.mocked(Config, true);
MockedConfig.mockImplementation(
Expand Down Expand Up @@ -1987,6 +1988,100 @@ ${JSON.stringify(
// Assert
expect(mockCheckNextSpeaker).not.toHaveBeenCalled();
});

it('does not run loop checks when skipLoopDetection is true', async () => {
// Arrange
// Ensure config returns true for skipLoopDetection
vi.spyOn(client['config'], 'getSkipLoopDetection').mockReturnValue(true);

// Replace loop detector with spies
const ldMock = {
turnStarted: vi.fn().mockResolvedValue(false),
addAndCheck: vi.fn().mockReturnValue(false),
reset: vi.fn(),
};
// @ts-expect-error override private for testing
client['loopDetector'] = ldMock;

const mockStream = (async function* () {
yield { type: 'content', value: 'Hello' };
yield { type: 'content', value: 'World' };
})();
mockTurnRunFn.mockReturnValue(mockStream);

const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;

const mockGenerator: Partial<ContentGenerator> = {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }),
generateContent: mockGenerateContentFn,
};
client['contentGenerator'] = mockGenerator as ContentGenerator;

// Act
const stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-id-loop-skip',
);
for await (const _ of stream) {
// consume
}

// Assert: methods not called due to skip
const ld = client['loopDetector'] as unknown as {
turnStarted: ReturnType<typeof vi.fn>;
addAndCheck: ReturnType<typeof vi.fn>;
};
expect(ld.turnStarted).not.toHaveBeenCalled();
expect(ld.addAndCheck).not.toHaveBeenCalled();
});

it('runs loop checks when skipLoopDetection is false', async () => {
// Arrange
vi.spyOn(client['config'], 'getSkipLoopDetection').mockReturnValue(false);

const turnStarted = vi.fn().mockResolvedValue(false);
const addAndCheck = vi.fn().mockReturnValue(false);
const reset = vi.fn();
// @ts-expect-error override private for testing
client['loopDetector'] = { turnStarted, addAndCheck, reset };

const mockStream = (async function* () {
yield { type: 'content', value: 'Hello' };
yield { type: 'content', value: 'World' };
})();
mockTurnRunFn.mockReturnValue(mockStream);

const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;

const mockGenerator: Partial<ContentGenerator> = {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }),
generateContent: mockGenerateContentFn,
};
client['contentGenerator'] = mockGenerator as ContentGenerator;

// Act
const stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-id-loop-run',
);
for await (const _ of stream) {
// consume
}

// Assert
expect(turnStarted).toHaveBeenCalledTimes(1);
expect(addAndCheck).toHaveBeenCalled();
});
});

describe('generateContent', () => {
Expand Down
18 changes: 11 additions & 7 deletions packages/core/src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -551,17 +551,21 @@ export class GeminiClient {

const turn = new Turn(this.getChat(), prompt_id);

const loopDetected = await this.loopDetector.turnStarted(signal);
if (loopDetected) {
yield { type: GeminiEventType.LoopDetected };
return turn;
if (!this.config.getSkipLoopDetection()) {
const loopDetected = await this.loopDetector.turnStarted(signal);
if (loopDetected) {
yield { type: GeminiEventType.LoopDetected };
return turn;
}
}

const resultStream = turn.run(request, signal);
for await (const event of resultStream) {
if (this.loopDetector.addAndCheck(event)) {
yield { type: GeminiEventType.LoopDetected };
return turn;
if (!this.config.getSkipLoopDetection()) {
if (this.loopDetector.addAndCheck(event)) {
yield { type: GeminiEventType.LoopDetected };
return turn;
}
}
yield event;
if (event.type === GeminiEventType.Error) {
Expand Down
Loading