Merge branch 'main' into chore/sync-gemini-cli-v0.3.4

This commit is contained in:
Mingholy
2025-09-16 19:51:32 +08:00
15 changed files with 1115 additions and 294 deletions

View File

@@ -238,6 +238,7 @@ export interface ConfigParameters {
skipNextSpeakerCheck?: boolean;
extensionManagement?: boolean;
enablePromptCompletion?: boolean;
skipLoopDetection?: boolean;
}
export class Config {
@@ -328,6 +329,7 @@ export class Config {
private readonly skipNextSpeakerCheck: boolean;
private readonly extensionManagement: boolean;
private readonly enablePromptCompletion: boolean = false;
private readonly skipLoopDetection: boolean;
private initialized: boolean = false;
readonly storage: Storage;
private readonly fileExclusions: FileExclusions;
@@ -410,6 +412,9 @@ export class Config {
this.chatCompression = params.chatCompression;
this.interactive = params.interactive ?? false;
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;
@@ -917,6 +922,10 @@ export class Config {
return this.enablePromptCompletion;
}
getSkipLoopDetection(): boolean {
return this.skipLoopDetection;
}
async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir, this.storage);

View File

@@ -260,6 +260,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(
@@ -2291,6 +2292,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', () => {

View File

@@ -554,17 +554,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) {