Merge tag 'v0.3.0' into chore/sync-gemini-cli-v0.3.0

This commit is contained in:
mingholy.lmh
2025-09-10 21:01:40 +08:00
583 changed files with 30160 additions and 10770 deletions

View File

@@ -4,25 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
expect,
it,
describe,
vi,
beforeEach,
afterEach,
MockInstance,
} from 'vitest';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import {
afterEach,
beforeEach,
describe,
expect,
it,
type MockInstance,
vi,
} from 'vitest';
import type { Config } from '../config/config.js';
import { getProjectHash } from '../utils/paths.js';
import {
ChatRecordingService,
ConversationRecord,
ToolCallRecord,
type ConversationRecord,
type ToolCallRecord,
} from './chatRecordingService.js';
import { Config } from '../config/config.js';
import { getProjectHash } from '../utils/paths.js';
vi.mock('node:fs');
vi.mock('node:path');
@@ -40,9 +40,11 @@ describe('ChatRecordingService', () => {
mockConfig = {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
getProjectTempDir: vi
.fn()
.mockReturnValue('/test/project/root/.gemini/tmp'),
storage: {
getProjectTempDir: vi
.fn()
.mockReturnValue('/test/project/root/.gemini/tmp'),
},
getModel: vi.fn().mockReturnValue('gemini-pro'),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;

View File

@@ -11,7 +11,7 @@ import { getProjectHash } from '../utils/paths.js';
import path from 'node:path';
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { PartListUnion } from '@google/genai';
import type { PartListUnion } from '@google/genai';
/**
* Token usage summary for a message or conversation.
@@ -136,7 +136,10 @@ export class ChatRecordingService {
this.cachedLastConvData = null;
} else {
// Create new session
const chatsDir = path.join(this.config.getProjectTempDir(), 'chats');
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
'chats',
);
fs.mkdirSync(chatsDir, { recursive: true });
const timestamp = new Date()
@@ -422,7 +425,10 @@ export class ChatRecordingService {
*/
deleteSession(sessionId: string): void {
try {
const chatsDir = path.join(this.config.getProjectTempDir(), 'chats');
const chatsDir = path.join(
this.config.storage.getProjectTempDir(),
'chats',
);
const sessionPath = path.join(chatsDir, `${sessionId}.json`);
fs.unlinkSync(sessionPath);
} catch (error) {

View File

@@ -5,9 +5,9 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { FileDiscoveryService } from './fileDiscoveryService.js';
describe('FileDiscoveryService', () => {

View File

@@ -4,9 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { GitIgnoreParser, GitIgnoreFilter } from '../utils/gitIgnoreParser.js';
import type { GitIgnoreFilter } from '../utils/gitIgnoreParser.js';
import { GitIgnoreParser } from '../utils/gitIgnoreParser.js';
import { isGitRepository } from '../utils/gitUtils.js';
import * as path from 'path';
import * as path from 'node:path';
const GEMINI_IGNORE_FILE_NAME = '.geminiignore';

View File

@@ -5,7 +5,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import fs from 'node:fs/promises';
import { StandardFileSystemService } from './fileSystemService.js';
vi.mock('fs/promises');

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'fs/promises';
import fs from 'node:fs/promises';
/**
* Interface for file system operations that may be delegated to different implementations

View File

@@ -6,9 +6,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GitService } from './gitService.js';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as os from 'os';
import { Storage } from '../config/storage.js';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import type { ChildProcess } from 'node:child_process';
import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
@@ -55,6 +56,7 @@ describe('GitService', () => {
let projectRoot: string;
let homedir: string;
let hash: string;
let storage: Storage;
beforeEach(async () => {
testRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-service-test-'));
@@ -100,6 +102,7 @@ describe('GitService', () => {
hoistedMockCommit.mockResolvedValue({
commit: 'initial',
});
storage = new Storage(projectRoot);
});
afterEach(async () => {
@@ -109,13 +112,13 @@ describe('GitService', () => {
describe('constructor', () => {
it('should successfully create an instance', () => {
expect(() => new GitService(projectRoot)).not.toThrow();
expect(() => new GitService(projectRoot, storage)).not.toThrow();
});
});
describe('verifyGitAvailability', () => {
it('should resolve true if git --version command succeeds', async () => {
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await expect(service.verifyGitAvailability()).resolves.toBe(true);
});
@@ -124,7 +127,7 @@ describe('GitService', () => {
callback(new Error('git not found'));
return {} as ChildProcess;
});
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await expect(service.verifyGitAvailability()).resolves.toBe(false);
});
});
@@ -135,14 +138,14 @@ describe('GitService', () => {
callback(new Error('git not found'));
return {} as ChildProcess;
});
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await expect(service.initialize()).rejects.toThrow(
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',
);
});
it('should call setupShadowGitRepository if Git is available', async () => {
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
const setupSpy = vi
.spyOn(service, 'setupShadowGitRepository')
.mockResolvedValue(undefined);
@@ -162,14 +165,14 @@ describe('GitService', () => {
});
it('should create history and repository directories', async () => {
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
const stats = await fs.stat(repoDir);
expect(stats.isDirectory()).toBe(true);
});
it('should create a .gitconfig file with the correct content', async () => {
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
const expectedConfigContent =
@@ -180,7 +183,7 @@ describe('GitService', () => {
it('should initialize git repo in historyDir if not already initialized', async () => {
hoistedMockCheckIsRepo.mockResolvedValue(false);
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
expect(hoistedMockSimpleGit).toHaveBeenCalledWith(repoDir);
expect(hoistedMockInit).toHaveBeenCalled();
@@ -188,7 +191,7 @@ describe('GitService', () => {
it('should not initialize git repo if already initialized', async () => {
hoistedMockCheckIsRepo.mockResolvedValue(true);
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
expect(hoistedMockInit).not.toHaveBeenCalled();
});
@@ -198,7 +201,7 @@ describe('GitService', () => {
const visibleGitIgnorePath = path.join(projectRoot, '.gitignore');
await fs.writeFile(visibleGitIgnorePath, gitignoreContent);
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
@@ -207,7 +210,7 @@ describe('GitService', () => {
});
it('should not create a .gitignore in shadow repo if project .gitignore does not exist', async () => {
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
@@ -221,7 +224,7 @@ describe('GitService', () => {
// Create a directory instead of a file to cause a read error
await fs.mkdir(visibleGitIgnorePath);
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
// EISDIR is the expected error code on Unix-like systems
await expect(service.setupShadowGitRepository()).rejects.toThrow(
/EISDIR: illegal operation on a directory, read|EBUSY: resource busy or locked, read/,
@@ -230,7 +233,7 @@ describe('GitService', () => {
it('should make an initial commit if no commits exist in history repo', async () => {
hoistedMockCheckIsRepo.mockResolvedValue(false);
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit', {
'--allow-empty': null,
@@ -239,7 +242,7 @@ describe('GitService', () => {
it('should not make an initial commit if commits already exist', async () => {
hoistedMockCheckIsRepo.mockResolvedValue(true);
const service = new GitService(projectRoot);
const service = new GitService(projectRoot, storage);
await service.setupShadowGitRepository();
expect(hoistedMockCommit).not.toHaveBeenCalled();
});

View File

@@ -4,24 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { isNodeError } from '../utils/errors.js';
import { exec } from 'node:child_process';
import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git';
import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { CheckRepoActions, simpleGit, type SimpleGit } from 'simple-git';
import type { Storage } from '../config/storage.js';
import { isNodeError } from '../utils/errors.js';
export class GitService {
private projectRoot: string;
private storage: Storage;
constructor(projectRoot: string) {
constructor(projectRoot: string, storage: Storage) {
this.projectRoot = path.resolve(projectRoot);
this.storage = storage;
}
private getHistoryDir(): string {
const hash = getProjectHash(this.projectRoot);
return path.join(os.homedir(), QWEN_DIR, 'history', hash);
return this.storage.getHistoryDir();
}
async initialize(): Promise<void> {
@@ -31,7 +31,13 @@ export class GitService {
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',
);
}
this.setupShadowGitRepository();
try {
await this.setupShadowGitRepository();
} catch (error) {
throw new Error(
`Failed to initialize checkpointing: ${error instanceof Error ? error.message : 'Unknown error'}. Please check that Git is working properly or disable checkpointing.`,
);
}
}
verifyGitAvailability(): Promise<boolean> {
@@ -105,10 +111,16 @@ export class GitService {
}
async createFileSnapshot(message: string): Promise<string> {
const repo = this.shadowGitRepository;
await repo.add('.');
const commitResult = await repo.commit(message);
return commitResult.commit;
try {
const repo = this.shadowGitRepository;
await repo.add('.');
const commitResult = await repo.commit(message);
return commitResult.commit;
} catch (error) {
throw new Error(
`Failed to create checkpoint snapshot: ${error instanceof Error ? error.message : 'Unknown error'}. Checkpointing may not be working properly.`,
);
}
}
async restoreProjectFromSnapshot(commitHash: string): Promise<void> {

View File

@@ -5,14 +5,14 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Config } from '../config/config.js';
import { GeminiClient } from '../core/client.js';
import {
GeminiEventType,
import type { Config } from '../config/config.js';
import type { GeminiClient } from '../core/client.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';
@@ -560,6 +560,30 @@ describe('LoopDetectionService', () => {
});
});
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 / 2);
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();
});
});
describe('Reset Functionality', () => {
it('tool call should reset content count', () => {
const contentEvent = createContentEvent('Some content.');

View File

@@ -4,11 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'crypto';
import { GeminiEventType, ServerGeminiStreamEvent } from '../core/turn.js';
import type { Content } from '@google/genai';
import { createHash } from 'node:crypto';
import type { ServerGeminiStreamEvent } from '../core/turn.js';
import { GeminiEventType } from '../core/turn.js';
import { logLoopDetected } from '../telemetry/loggers.js';
import { LoopDetectedEvent, LoopType } from '../telemetry/types.js';
import { Config, DEFAULT_GEMINI_FLASH_MODEL } from '../config/config.js';
import type { Config } from '../config/config.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/config.js';
import {
isFunctionCall,
isFunctionResponse,
} from '../utils/messageInspectors.js';
const TOOL_CALL_LOOP_THRESHOLD = 5;
const CONTENT_LOOP_THRESHOLD = 10;
@@ -169,8 +176,16 @@ export class LoopDetectionService {
/(^|\n)\s*[*-+]\s/.test(content) || /(^|\n)\s*\d+\.\s/.test(content);
const hasHeading = /(^|\n)#+\s/.test(content);
const hasBlockquote = /(^|\n)>\s/.test(content);
const isDivider = /^[+-_=*\u2500-\u257F]+$/.test(content);
if (numFences || hasTable || hasListItem || hasHeading || hasBlockquote) {
if (
numFences ||
hasTable ||
hasListItem ||
hasHeading ||
hasBlockquote ||
isDivider
) {
// Reset tracking when different content elements are detected to avoid analyzing content
// that spans across different element boundaries.
this.resetContentTracking();
@@ -179,7 +194,7 @@ export class LoopDetectionService {
const wasInCodeBlock = this.inCodeBlock;
this.inCodeBlock =
numFences % 2 === 0 ? this.inCodeBlock : !this.inCodeBlock;
if (wasInCodeBlock || this.inCodeBlock) {
if (wasInCodeBlock || this.inCodeBlock || isDivider) {
return false;
}
@@ -318,12 +333,35 @@ export class LoopDetectionService {
return originalChunk === currentChunk;
}
private trimRecentHistory(recentHistory: Content[]): Content[] {
// A function response must be preceded by a function call.
// Continuously removes dangling function calls from the end of the history
// until the last turn is not a function call.
while (
recentHistory.length > 0 &&
isFunctionCall(recentHistory[recentHistory.length - 1])
) {
recentHistory.pop();
}
// A function response should follow a function call.
// Continuously removes leading function responses from the beginning of history
// until the first turn is not a function response.
while (recentHistory.length > 0 && isFunctionResponse(recentHistory[0])) {
recentHistory.shift();
}
return recentHistory;
}
private async checkForLoopWithLLM(signal: AbortSignal) {
const recentHistory = this.config
.getGeminiClient()
.getHistory()
.slice(-LLM_LOOP_CHECK_HISTORY_COUNT);
const trimmedHistory = this.trimRecentHistory(recentHistory);
const prompt = `You are a sophisticated AI diagnostic agent specializing in identifying when a conversational AI is stuck in an unproductive state. Your task is to analyze the provided conversation history and determine if the assistant has ceased to make meaningful progress.
An unproductive state is characterized by one or more of the following patterns over the last 5 or more assistant turns:
@@ -337,7 +375,7 @@ For example, a series of 'tool_A' or 'tool_B' tool calls that make small, distin
Please analyze the conversation history to determine the possibility that the conversation is stuck in a repetitive, non-productive state.`;
const contents = [
...recentHistory,
...trimmedHistory,
{ role: 'user', parts: [{ text: prompt }] },
];
const schema: Record<string, unknown> = {

View File

@@ -4,13 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import EventEmitter from 'events';
import { Readable } from 'stream';
import { type ChildProcess } from 'child_process';
import EventEmitter from 'events';
import type { Readable } from 'stream';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import {
ShellExecutionService,
ShellOutputEvent,
type ShellOutputEvent,
} from './shellExecutionService.js';
// Hoisted Mocks
@@ -292,7 +292,7 @@ describe('ShellExecutionService', () => {
expect(mockPtySpawn).toHaveBeenCalledWith(
'cmd.exe',
['/c', 'dir "foo bar"'],
'/c dir "foo bar"',
expect.any(Object),
);
});

View File

@@ -4,14 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getPty, PtyImplementation } from '../utils/getPty.js';
import pkg from '@xterm/headless';
import { spawn as cpSpawn } from 'child_process';
import { TextDecoder } from 'util';
import os from 'os';
import stripAnsi from 'strip-ansi';
import { TextDecoder } from 'util';
import type { PtyImplementation } from '../utils/getPty.js';
import { getPty } from '../utils/getPty.js';
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
import { isBinary } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import stripAnsi from 'strip-ansi';
const { Terminal } = pkg;
const SIGKILL_TIMEOUT_MS = 200;
@@ -140,6 +141,7 @@ export class ShellExecutionService {
const child = cpSpawn(commandToExecute, [], {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsVerbatimArguments: true,
shell: isWindows ? true : 'bash',
detached: !isWindows,
env: {
@@ -321,7 +323,7 @@ export class ShellExecutionService {
const isWindows = os.platform() === 'win32';
const shell = isWindows ? 'cmd.exe' : 'bash';
const args = isWindows
? ['/c', commandToExecute]
? `/c ${commandToExecute}`
: ['-c', commandToExecute];
const ptyProcess = ptyInfo?.module.spawn(shell, args, {