fix: add patch for sync upstream

This commit is contained in:
mingholy.lmh
2025-08-20 22:24:53 +08:00
parent c546d86d44
commit 1e2bbd1be3
23 changed files with 508 additions and 303 deletions

42
package-lock.json generated
View File

@@ -979,6 +979,27 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@google/genai": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.9.0.tgz",
"integrity": "sha512-w9P93OXKPMs9H1mfAx9+p3zJqQGrWBGdvK/SVc7cLZEXNHr/3+vW2eif7ZShA6wU24rNLn9z9MK2vQFUvNRI2Q==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^9.14.2",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.11.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"node_modules/@grpc/grpc-js": { "node_modules/@grpc/grpc-js": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
@@ -12550,6 +12571,27 @@
"node": ">=20" "node": ">=20"
} }
}, },
"packages/core/node_modules/@google/genai": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.13.0.tgz",
"integrity": "sha512-BxilXzE8cJ0zt5/lXk6KwuBcIT9P2Lbi2WXhwWMbxf1RNeC68/8DmYQqMrzQP333CieRMdbDXs0eNCphLoScWg==",
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^9.14.2",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.11.0"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"packages/core/node_modules/ajv": { "packages/core/node_modules/ajv": {
"version": "8.17.1", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",

View File

@@ -9,7 +9,7 @@ import * as os from 'os';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core'; import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments } from './config.js'; import { loadCliConfig, parseArguments, CliArgs } from './config.js';
import { Settings } from './settings.js'; import { Settings } from './settings.js';
import { Extension } from './extension.js'; import { Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core'; import * as ServerConfig from '@qwen-code/qwen-code-core';
@@ -242,7 +242,7 @@ describe('parseArguments', () => {
await expect(parseArguments()).rejects.toThrow('process.exit called'); await expect(parseArguments()).rejects.toThrow('process.exit called');
expect(mockConsoleError).toHaveBeenCalledWith( expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Invalid values:'), expect.stringContaining('无效的选项值:'),
); );
mockExit.mockRestore(); mockExit.mockRestore();
@@ -566,6 +566,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
const settings: Settings = {}; const settings: Settings = {};
const extensions: Extension[] = [ const extensions: Extension[] = [
{ {
path: '/path/to/ext1',
config: { config: {
name: 'ext1', name: 'ext1',
version: '1.0.0', version: '1.0.0',
@@ -573,6 +574,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
contextFiles: ['/path/to/ext1/QWEN.md'], contextFiles: ['/path/to/ext1/QWEN.md'],
}, },
{ {
path: '/path/to/ext2',
config: { config: {
name: 'ext2', name: 'ext2',
version: '1.0.0', version: '1.0.0',
@@ -580,6 +582,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
contextFiles: [], contextFiles: [],
}, },
{ {
path: '/path/to/ext3',
config: { config: {
name: 'ext3', name: 'ext3',
version: '1.0.0', version: '1.0.0',
@@ -645,6 +648,7 @@ describe('mergeMcpServers', () => {
}; };
const extensions: Extension[] = [ const extensions: Extension[] = [
{ {
path: '/path/to/ext1',
config: { config: {
name: 'ext1', name: 'ext1',
version: '1.0.0', version: '1.0.0',
@@ -743,6 +747,7 @@ describe('mergeExcludeTools', () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [ const extensions: Extension[] = [
{ {
path: '/path/to/ext1',
config: { config: {
name: 'ext1', name: 'ext1',
version: '1.0.0', version: '1.0.0',
@@ -751,6 +756,7 @@ describe('mergeExcludeTools', () => {
contextFiles: [], contextFiles: [],
}, },
{ {
path: '/path/to/ext2',
config: { config: {
name: 'ext2', name: 'ext2',
version: '1.0.0', version: '1.0.0',
@@ -777,6 +783,7 @@ describe('mergeExcludeTools', () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [ const extensions: Extension[] = [
{ {
path: '/path/to/ext1',
config: { config: {
name: 'ext1', name: 'ext1',
version: '1.0.0', version: '1.0.0',

View File

@@ -7,7 +7,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { getErrorMessage, isWithinRoot } from '@google/gemini-cli-core'; import { getErrorMessage, isWithinRoot } from '@qwen-code/qwen-code-core';
import stripJsonComments from 'strip-json-comments'; import stripJsonComments from 'strip-json-comments';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json'; export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { DetectedIde, getIdeInfo } from '@google/gemini-cli-core'; import { DetectedIde, getIdeInfo } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { import {
RadioButtonSelect, RadioButtonSelect,

View File

@@ -111,7 +111,6 @@ export function AuthDialog({
useKeypress( useKeypress(
(key) => { (key) => {
if (showOpenAIKeyPrompt) { if (showOpenAIKeyPrompt) {
return; return;
} }

View File

@@ -53,8 +53,8 @@ export function EditorSettingsDialog({
settings.forScope(selectedScope).settings.preferredEditor; settings.forScope(selectedScope).settings.preferredEditor;
let editorIndex = currentPreference let editorIndex = currentPreference
? editorItems.findIndex( ? editorItems.findIndex(
(item: EditorDisplay) => item.type === currentPreference, (item: EditorDisplay) => item.type === currentPreference,
) )
: 0; : 0;
if (editorIndex === -1) { if (editorIndex === -1) {
console.error(`Editor is not supported: ${currentPreference}`); console.error(`Editor is not supported: ${currentPreference}`);

View File

@@ -7,7 +7,7 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { useFolderTrust } from './useFolderTrust.js'; import { useFolderTrust } from './useFolderTrust.js';
import { type Config } from '@google/gemini-cli-core'; import { type Config } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js'; import { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { import {

View File

@@ -5,7 +5,7 @@
*/ */
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { type Config } from '@google/gemini-cli-core'; import { type Config } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js'; import { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js'; import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';

View File

@@ -17,7 +17,7 @@ import {
KittySequenceOverflowEvent, KittySequenceOverflowEvent,
logKittySequenceOverflow, logKittySequenceOverflow,
Config, Config,
} from '@google/gemini-cli-core'; } from '@qwen-code/qwen-code-core';
import { FOCUS_IN, FOCUS_OUT } from './useFocus.js'; import { FOCUS_IN, FOCUS_OUT } from './useFocus.js';
const ESC = '\u001B'; const ESC = '\u001B';

View File

@@ -20,7 +20,6 @@ import {
} from '../core/contentGenerator.js'; } from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js'; import { GeminiClient } from '../core/client.js';
import { GitService } from '../services/gitService.js'; import { GitService } from '../services/gitService.js';
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
vi.mock('fs', async (importOriginal) => { vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>(); const actual = await importOriginal<typeof import('fs')>();
@@ -140,10 +139,6 @@ describe('Server Config (config.ts)', () => {
beforeEach(() => { beforeEach(() => {
// Reset mocks if necessary // Reset mocks if necessary
vi.clearAllMocks(); vi.clearAllMocks();
vi.spyOn(
ClearcutLogger.prototype,
'logStartSessionEvent',
).mockImplementation(() => undefined);
}); });
describe('initialize', () => { describe('initialize', () => {
@@ -499,17 +494,6 @@ describe('Server Config (config.ts)', () => {
expect(config.getUsageStatisticsEnabled()).toBe(enabled); expect(config.getUsageStatisticsEnabled()).toBe(enabled);
}, },
); );
it('logs the session start event', () => {
new Config({
...baseParams,
usageStatisticsEnabled: true,
});
expect(
ClearcutLogger.prototype.logStartSessionEvent,
).toHaveBeenCalledOnce();
});
}); });
describe('Telemetry Settings', () => { describe('Telemetry Settings', () => {

View File

@@ -70,6 +70,7 @@ export type ContentGeneratorConfig = {
max_tokens?: number; max_tokens?: number;
}; };
proxy?: string | undefined; proxy?: string | undefined;
userAgent?: string;
}; };
export function createContentGeneratorConfig( export function createContentGeneratorConfig(

View File

@@ -644,7 +644,7 @@ describe('OpenAIContentGenerator', () => {
model: 'text-embedding-ada-002', model: 'text-embedding-ada-002',
}; };
const _result = await generator.embedContent(request); await generator.embedContent(request);
expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({ expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({
model: 'text-embedding-ada-002', model: 'text-embedding-ada-002',
@@ -1582,7 +1582,7 @@ describe('OpenAIContentGenerator', () => {
describe('error suppression functionality', () => { describe('error suppression functionality', () => {
it('should allow subclasses to suppress error logging', async () => { it('should allow subclasses to suppress error logging', async () => {
class TestGenerator extends OpenAIContentGenerator { class TestGenerator extends OpenAIContentGenerator {
protected shouldSuppressErrorLogging(): boolean { protected override shouldSuppressErrorLogging(): boolean {
return true; // Always suppress for this test return true; // Always suppress for this test
} }
} }

View File

@@ -24,11 +24,6 @@ describe('ide-installer', () => {
expect(installer).toBeInstanceOf(Object); expect(installer).toBeInstanceOf(Object);
}); });
it('should return null for "vscodium" (not implemented)', () => {
const installer = getIdeInstaller(DetectedIde.VSCodium);
expect(installer).toBeNull();
});
it('should return null for an unknown IDE', () => { it('should return null for an unknown IDE', () => {
const installer = getIdeInstaller('unknown' as DetectedIde); const installer = getIdeInstaller('unknown' as DetectedIde);
expect(installer).toBeNull(); expect(installer).toBeNull();

View File

@@ -65,7 +65,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/** /**
* Override error logging behavior to suppress auth errors during token refresh * Override error logging behavior to suppress auth errors during token refresh
*/ */
protected shouldSuppressErrorLogging( protected override shouldSuppressErrorLogging(
error: unknown, error: unknown,
_request: GenerateContentParameters, _request: GenerateContentParameters,
): boolean { ): boolean {
@@ -76,7 +76,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/** /**
* Override to use dynamic token and endpoint * Override to use dynamic token and endpoint
*/ */
async generateContent( override async generateContent(
request: GenerateContentParameters, request: GenerateContentParameters,
userPromptId: string, userPromptId: string,
): Promise<GenerateContentResponse> { ): Promise<GenerateContentResponse> {
@@ -100,7 +100,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/** /**
* Override to use dynamic token and endpoint * Override to use dynamic token and endpoint
*/ */
async generateContentStream( override async generateContentStream(
request: GenerateContentParameters, request: GenerateContentParameters,
userPromptId: string, userPromptId: string,
): Promise<AsyncGenerator<GenerateContentResponse>> { ): Promise<AsyncGenerator<GenerateContentResponse>> {
@@ -127,7 +127,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/** /**
* Override to use dynamic token and endpoint * Override to use dynamic token and endpoint
*/ */
async countTokens( override async countTokens(
request: CountTokensParameters, request: CountTokensParameters,
): Promise<CountTokensResponse> { ): Promise<CountTokensResponse> {
return this.withValidToken(async (token) => { return this.withValidToken(async (token) => {
@@ -148,7 +148,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/** /**
* Override to use dynamic token and endpoint * Override to use dynamic token and endpoint
*/ */
async embedContent( override async embedContent(
request: EmbedContentParameters, request: EmbedContentParameters,
): Promise<EmbedContentResponse> { ): Promise<EmbedContentResponse> {
return this.withValidToken(async (token) => { return this.withValidToken(async (token) => {

View File

@@ -223,17 +223,9 @@ describe('Type Guards', () => {
describe('QwenOAuth2Client', () => { describe('QwenOAuth2Client', () => {
let client: QwenOAuth2Client; let client: QwenOAuth2Client;
let _mockConfig: Config;
let originalFetch: typeof global.fetch; let originalFetch: typeof global.fetch;
beforeEach(() => { beforeEach(() => {
// Setup mock config
_mockConfig = {
getQwenClientId: vi.fn().mockReturnValue('test-client-id'),
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
getProxy: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
// Create client instance // Create client instance
client = new QwenOAuth2Client({ proxy: undefined }); client = new QwenOAuth2Client({ proxy: undefined });
@@ -1010,7 +1002,6 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
describe('authWithQwenDeviceFlow - Comprehensive Testing', () => { describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
let mockConfig: Config; let mockConfig: Config;
let originalFetch: typeof global.fetch; let originalFetch: typeof global.fetch;
let _client: QwenOAuth2Client;
beforeEach(() => { beforeEach(() => {
mockConfig = { mockConfig = {
@@ -1018,7 +1009,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false), isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
} as unknown as Config; } as unknown as Config;
_client = new QwenOAuth2Client({ proxy: undefined }); new QwenOAuth2Client({ proxy: undefined });
originalFetch = global.fetch; originalFetch = global.fetch;
global.fetch = vi.fn(); global.fetch = vi.fn();

View File

@@ -234,11 +234,8 @@ export interface IQwenOAuth2Client {
*/ */
export class QwenOAuth2Client implements IQwenOAuth2Client { export class QwenOAuth2Client implements IQwenOAuth2Client {
private credentials: QwenCredentials = {}; private credentials: QwenCredentials = {};
private proxy?: string;
constructor(options: { proxy?: string }) { constructor(_options?: { proxy?: string }) {}
this.proxy = options.proxy;
}
setCredentials(credentials: QwenCredentials): void { setCredentials(credentials: QwenCredentials): void {
this.credentials = credentials; this.credentials = credentials;

View File

@@ -4,46 +4,47 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { logs, LogRecord, LogAttributes } from '@opentelemetry/api-logs'; import { LogAttributes, LogRecord, logs } from '@opentelemetry/api-logs';
import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import { Config } from '../config/config.js'; import { Config } from '../config/config.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { import {
EVENT_API_ERROR, EVENT_API_ERROR,
EVENT_API_REQUEST, EVENT_API_REQUEST,
EVENT_API_RESPONSE, EVENT_API_RESPONSE,
EVENT_CLI_CONFIG, EVENT_CLI_CONFIG,
EVENT_FLASH_FALLBACK,
EVENT_IDE_CONNECTION, EVENT_IDE_CONNECTION,
EVENT_NEXT_SPEAKER_CHECK,
EVENT_SLASH_COMMAND,
EVENT_TOOL_CALL, EVENT_TOOL_CALL,
EVENT_USER_PROMPT, EVENT_USER_PROMPT,
EVENT_FLASH_FALLBACK,
EVENT_NEXT_SPEAKER_CHECK,
SERVICE_NAME, SERVICE_NAME,
EVENT_SLASH_COMMAND,
} from './constants.js'; } from './constants.js';
import {
recordApiErrorMetrics,
recordApiResponseMetrics,
recordTokenUsageMetrics,
recordToolCallMetrics,
} from './metrics.js';
import { QwenLogger } from './qwen-logger/qwen-logger.js';
import { isTelemetrySdkInitialized } from './sdk.js';
import { import {
ApiErrorEvent, ApiErrorEvent,
ApiRequestEvent, ApiRequestEvent,
ApiResponseEvent, ApiResponseEvent,
FlashFallbackEvent,
IdeConnectionEvent, IdeConnectionEvent,
KittySequenceOverflowEvent,
LoopDetectedEvent,
NextSpeakerCheckEvent,
SlashCommandEvent,
StartSessionEvent, StartSessionEvent,
ToolCallEvent, ToolCallEvent,
UserPromptEvent, UserPromptEvent,
FlashFallbackEvent,
NextSpeakerCheckEvent,
LoopDetectedEvent,
SlashCommandEvent,
KittySequenceOverflowEvent,
} from './types.js'; } from './types.js';
import { import { UiEvent, uiTelemetryService } from './uiTelemetry.js';
recordApiErrorMetrics,
recordTokenUsageMetrics,
recordApiResponseMetrics,
recordToolCallMetrics,
} from './metrics.js';
import { isTelemetrySdkInitialized } from './sdk.js';
import { uiTelemetryService, UiEvent } from './uiTelemetry.js';
import { QwenLogger } from './qwen-logger/qwen-logger.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
const shouldLogUserPrompts = (config: Config): boolean => const shouldLogUserPrompts = (config: Config): boolean =>
config.getTelemetryLogPromptsEnabled(); config.getTelemetryLogPromptsEnabled();

View File

@@ -314,6 +314,11 @@ describe('MemoryTool', () => {
memoryTool = new MemoryTool(); memoryTool = new MemoryTool();
// Mock fs.readFile to return empty string (file doesn't exist) // Mock fs.readFile to return empty string (file doesn't exist)
vi.mocked(fs.readFile).mockResolvedValue(''); vi.mocked(fs.readFile).mockResolvedValue('');
// Clear allowlist before each test to ensure clean state
const invocation = memoryTool.build({ fact: 'test', scope: 'global' });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(invocation.constructor as any).allowlist.clear();
}); });
it('should return confirmation details when memory file is not allowlisted for global scope', async () => { it('should return confirmation details when memory file is not allowlisted for global scope', async () => {

View File

@@ -201,7 +201,7 @@ class MemoryToolInvocation extends BaseToolInvocation<
getDescription(): string { getDescription(): string {
const scope = this.params.scope || 'global'; const scope = this.params.scope || 'global';
const memoryFilePath = getMemoryFilePath(scope); const memoryFilePath = getMemoryFilePath(scope);
return `in ${tildeifyPath(memoryFilePath)} (${scope})`; return `${tildeifyPath(memoryFilePath)} (${scope})`;
} }
override async shouldConfirmExecute( override async shouldConfirmExecute(

View File

@@ -366,6 +366,253 @@ describe('ShellTool', () => {
await promise; await promise;
}); });
}); });
describe('addCoAuthorToGitCommit', () => {
it('should add co-author to git commit with double quotes', async () => {
const command = 'git commit -m "Initial commit"';
const invocation = shellTool.build({ command });
const promise = invocation.execute(mockAbortSignal);
// Mock the shell execution to return success
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
});
await promise;
// Verify that the command was executed with co-author added
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should add co-author to git commit with single quotes', async () => {
const command = "git commit -m 'Fix bug'";
const invocation = shellTool.build({ command });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should handle git commit with additional flags', async () => {
const command = 'git commit -a -m "Add feature"';
const invocation = shellTool.build({ command });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should not modify non-git commands', async () => {
const command = 'npm install';
const invocation = shellTool.build({ command });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
});
await promise;
// On Linux, commands are wrapped with pgrep functionality
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining('npm install'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should not modify git commands without -m flag', async () => {
const command = 'git commit';
const invocation = shellTool.build({ command });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
});
await promise;
// On Linux, commands are wrapped with pgrep functionality
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining('git commit'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should handle git commit with escaped quotes in message', async () => {
const command = 'git commit -m "Fix \\"quoted\\" text"';
const invocation = shellTool.build({ command });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should not add co-author when disabled in config', async () => {
// Mock config with disabled co-author
(mockConfig.getGitCoAuthor as Mock).mockReturnValue({
enabled: false,
name: 'Qwen-Coder',
email: 'qwen-coder@alibabacloud.com',
});
const command = 'git commit -m "Initial commit"';
const invocation = shellTool.build({ command });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
});
await promise;
// On Linux, commands are wrapped with pgrep functionality
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining('git commit -m "Initial commit"'),
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
it('should use custom name and email from config', async () => {
// Mock config with custom co-author details
(mockConfig.getGitCoAuthor as Mock).mockReturnValue({
enabled: true,
name: 'Custom Bot',
email: 'custom@example.com',
});
const command = 'git commit -m "Test commit"';
const invocation = shellTool.build({ command });
const promise = invocation.execute(mockAbortSignal);
resolveExecutionPromise({
rawOutput: Buffer.from(''),
output: '',
stdout: '',
stderr: '',
exitCode: 0,
signal: null,
error: null,
aborted: false,
pid: 12345,
});
await promise;
expect(mockShellExecutionService).toHaveBeenCalledWith(
expect.stringContaining(
'Co-authored-by: Custom Bot <custom@example.com>',
),
expect.any(String),
expect.any(Function),
mockAbortSignal,
);
});
});
}); });
describe('shouldConfirmExecute', () => { describe('shouldConfirmExecute', () => {
@@ -396,123 +643,6 @@ describe('ShellTool', () => {
expect(() => shellTool.build({ command: '' })).toThrow(); expect(() => shellTool.build({ command: '' })).toThrow();
}); });
}); });
describe('addCoAuthorToGitCommit', () => {
it('should add co-author to git commit with double quotes', () => {
const command = 'git commit -m "Initial commit"';
// Use public test method
const result = (
shellTool as unknown as {
addCoAuthorToGitCommit: (command: string) => string;
}
).addCoAuthorToGitCommit(command);
expect(result).toBe(
`git commit -m "Initial commit
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>"`,
);
});
it('should add co-author to git commit with single quotes', () => {
const command = "git commit -m 'Fix bug'";
const result = (
shellTool as unknown as {
addCoAuthorToGitCommit: (command: string) => string;
}
).addCoAuthorToGitCommit(command);
expect(result).toBe(
`git commit -m 'Fix bug
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>'`,
);
});
it('should handle git commit with additional flags', () => {
const command = 'git commit -a -m "Add feature"';
const result = (
shellTool as unknown as {
addCoAuthorToGitCommit: (command: string) => string;
}
).addCoAuthorToGitCommit(command);
expect(result).toBe(
`git commit -a -m "Add feature
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>"`,
);
});
it('should not modify non-git commands', () => {
const command = 'npm install';
const result = (
shellTool as unknown as {
addCoAuthorToGitCommit: (command: string) => string;
}
).addCoAuthorToGitCommit(command);
expect(result).toBe('npm install');
});
it('should not modify git commands without -m flag', () => {
const command = 'git commit';
const result = (
shellTool as unknown as {
addCoAuthorToGitCommit: (command: string) => string;
}
).addCoAuthorToGitCommit(command);
expect(result).toBe('git commit');
});
it('should handle git commit with escaped quotes in message', () => {
const command = 'git commit -m "Fix \\"quoted\\" text"';
const result = (
shellTool as unknown as {
addCoAuthorToGitCommit: (command: string) => string;
}
).addCoAuthorToGitCommit(command);
expect(result).toBe(
`git commit -m "Fix \\"quoted\\" text
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>"`,
);
});
it('should not add co-author when disabled in config', () => {
// Mock config with disabled co-author
(mockConfig.getGitCoAuthor as Mock).mockReturnValue({
enabled: false,
name: 'Qwen-Coder',
email: 'qwen-coder@alibabacloud.com',
});
const command = 'git commit -m "Initial commit"';
const result = (
shellTool as unknown as {
addCoAuthorToGitCommit: (command: string) => string;
}
).addCoAuthorToGitCommit(command);
expect(result).toBe('git commit -m "Initial commit"');
});
it('should use custom name and email from config', () => {
// Mock config with custom co-author details
(mockConfig.getGitCoAuthor as Mock).mockReturnValue({
enabled: true,
name: 'Custom Bot',
email: 'custom@example.com',
});
const command = 'git commit -m "Test commit"';
const result = (
shellTool as unknown as {
addCoAuthorToGitCommit: (command: string) => string;
}
).addCoAuthorToGitCommit(command);
expect(result).toBe(
`git commit -m "Test commit
Co-authored-by: Custom Bot <custom@example.com>"`,
);
});
});
}); });
describe('validateToolParams', () => { describe('validateToolParams', () => {

View File

@@ -382,9 +382,7 @@ export class ShellTool extends BaseDeclarativeTool<
); );
} }
protected override validateToolParams( override validateToolParams(params: ShellToolParams): string | null {
params: ShellToolParams,
): string | null {
const commandCheck = isCommandAllowed(params.command, this.config); const commandCheck = isCommandAllowed(params.command, this.config);
if (!commandCheck.allowed) { if (!commandCheck.allowed) {
if (!commandCheck.reason) { if (!commandCheck.reason) {

View File

@@ -6,15 +6,18 @@
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { import {
BaseTool, BaseDeclarativeTool,
ToolResult, BaseToolInvocation,
Kind,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
Icon, ToolInvocation,
ToolResult,
} from './tools.js'; } from './tools.js';
import { Config, ApprovalMode } from '../config/config.js'; import { Config, ApprovalMode } from '../config/config.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js'; import { getResponseText } from '../utils/generateContentResponseUtilities.js';
import { fetchWithTimeout } from '../utils/fetch.js'; import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici'; import { ProxyAgent, setGlobalDispatcher } from 'undici';
@@ -35,18 +38,158 @@ export interface WebFetchToolParams {
prompt: string; prompt: string;
} }
/**
* Implementation of the WebFetch tool invocation logic
*/
class WebFetchToolInvocation extends BaseToolInvocation<
WebFetchToolParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: WebFetchToolParams,
) {
super(params);
}
private async executeDirectFetch(signal: AbortSignal): Promise<ToolResult> {
let url = this.params.url;
// Convert GitHub blob URL to raw URL
if (url.includes('github.com') && url.includes('/blob/')) {
url = url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
console.debug(
`[WebFetchTool] Converted GitHub blob URL to raw URL: ${url}`,
);
}
try {
console.debug(`[WebFetchTool] Fetching content from: ${url}`);
const response = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS);
if (!response.ok) {
const errorMessage = `Request failed with status code ${response.status} ${response.statusText}`;
console.error(`[WebFetchTool] ${errorMessage}`);
throw new Error(errorMessage);
}
console.debug(`[WebFetchTool] Successfully fetched content from ${url}`);
const html = await response.text();
const textContent = convert(html, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'skip' },
],
}).substring(0, MAX_CONTENT_LENGTH);
console.debug(
`[WebFetchTool] Converted HTML to text (${textContent.length} characters)`,
);
const geminiClient = this.config.getGeminiClient();
const fallbackPrompt = `The user requested the following: "${this.params.prompt}".
I have fetched the content from ${this.params.url}. Please use the following content to answer the user's request.
---
${textContent}
---`;
console.debug(
`[WebFetchTool] Processing content with prompt: "${this.params.prompt}"`,
);
const result = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: fallbackPrompt }] }],
{},
signal,
);
const resultText = getResponseText(result) || '';
console.debug(
`[WebFetchTool] Successfully processed content from ${this.params.url}`,
);
return {
llmContent: resultText,
returnDisplay: `Content from ${this.params.url} processed successfully.`,
};
} catch (e) {
const error = e as Error;
const errorMessage = `Error during fetch for ${url}: ${error.message}`;
console.error(`[WebFetchTool] ${errorMessage}`, error);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
};
}
}
override getDescription(): string {
const displayPrompt =
this.params.prompt.length > 100
? this.params.prompt.substring(0, 97) + '...'
: this.params.prompt;
return `Fetching content from ${this.params.url} and processing with prompt: "${displayPrompt}"`;
}
override async shouldConfirmExecute(): Promise<
ToolCallConfirmationDetails | false
> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
}
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: `Confirm Web Fetch`,
prompt: `Fetch content from ${this.params.url} and process with: ${this.params.prompt}`,
urls: [this.params.url],
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
},
};
return confirmationDetails;
}
async execute(signal: AbortSignal): Promise<ToolResult> {
// Check if URL is private/localhost
const isPrivate = isPrivateIp(this.params.url);
if (isPrivate) {
console.debug(
`[WebFetchTool] Private IP detected for ${this.params.url}, using direct fetch`,
);
} else {
console.debug(
`[WebFetchTool] Public URL detected for ${this.params.url}, using direct fetch`,
);
}
return this.executeDirectFetch(signal);
}
}
/** /**
* Implementation of the WebFetch tool logic * Implementation of the WebFetch tool logic
*/ */
export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> { export class WebFetchTool extends BaseDeclarativeTool<
WebFetchToolParams,
ToolResult
> {
static readonly Name: string = 'web_fetch'; static readonly Name: string = 'web_fetch';
constructor(private readonly config: Config) { constructor(private readonly config: Config) {
super( super(
WebFetchTool.Name, WebFetchTool.Name,
'WebFetch', 'WebFetch',
'Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large', 'Fetches content from a specified URL and processes it using an AI model\n- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large\n - Supports both public and private/localhost URLs using direct fetch',
Icon.Globe, Kind.Fetch,
{ {
properties: { properties: {
url: { url: {
@@ -68,64 +211,9 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
} }
} }
private async executeFetch( protected override validateToolParams(
params: WebFetchToolParams, params: WebFetchToolParams,
signal: AbortSignal, ): string | null {
): Promise<ToolResult> {
let url = params.url;
// Convert GitHub blob URL to raw URL
if (url.includes('github.com') && url.includes('/blob/')) {
url = url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
try {
const response = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS);
if (!response.ok) {
throw new Error(
`Request failed with status code ${response.status} ${response.statusText}`,
);
}
const html = await response.text();
const textContent = convert(html, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'skip' },
],
}).substring(0, MAX_CONTENT_LENGTH);
const geminiClient = this.config.getGeminiClient();
const fallbackPrompt = `The user requested the following: "${params.prompt}".
I have fetched the content from ${params.url}. Please use the following content to answer the user's request.
---
${textContent}
---`;
const result = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: fallbackPrompt }] }],
{},
signal,
);
const resultText = getResponseText(result) || '';
return {
llmContent: resultText,
returnDisplay: `Content from ${params.url} processed successfully.`,
};
} catch (e) {
const error = e as Error;
const errorMessage = `Error during fetch for ${url}: ${error.message}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
};
}
}
validateParams(params: WebFetchToolParams): string | null {
const errors = SchemaValidator.validate( const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema, this.schema.parametersJsonSchema,
params, params,
@@ -148,52 +236,9 @@ ${textContent}
return null; return null;
} }
getDescription(params: WebFetchToolParams): string { protected createInvocation(
const displayPrompt =
params.prompt.length > 100
? params.prompt.substring(0, 97) + '...'
: params.prompt;
return `Fetching content from ${params.url} and processing with prompt: "${displayPrompt}"`;
}
async shouldConfirmExecute(
params: WebFetchToolParams, params: WebFetchToolParams,
): Promise<ToolCallConfirmationDetails | false> { ): ToolInvocation<WebFetchToolParams, ToolResult> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { return new WebFetchToolInvocation(this.config, params);
return false;
}
const validationError = this.validateParams(params);
if (validationError) {
return false;
}
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: `Confirm Web Fetch`,
prompt: `Fetch content from ${params.url} and process with: ${params.prompt}`,
urls: [params.url],
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
},
};
return confirmationDetails;
}
async execute(
params: WebFetchToolParams,
signal: AbortSignal,
): Promise<ToolResult> {
const validationError = this.validateParams(params);
if (validationError) {
return {
llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`,
returnDisplay: validationError,
};
}
return this.executeFetch(params, signal);
} }
} }

View File

@@ -79,6 +79,14 @@ describe('getEnvironmentContext', () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2025-08-05T12:00:00Z')); vi.setSystemTime(new Date('2025-08-05T12:00:00Z'));
// Mock the locale to ensure consistent English date formatting
vi.stubGlobal('Intl', {
...global.Intl,
DateTimeFormat: vi.fn().mockImplementation(() => ({
format: vi.fn().mockReturnValue('Tuesday, August 5, 2025'),
})),
});
mockToolRegistry = { mockToolRegistry = {
getTool: vi.fn(), getTool: vi.fn(),
}; };
@@ -97,6 +105,7 @@ describe('getEnvironmentContext', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals();
vi.resetAllMocks(); vi.resetAllMocks();
}); });
@@ -106,7 +115,8 @@ describe('getEnvironmentContext', () => {
expect(parts.length).toBe(1); expect(parts.length).toBe(1);
const context = parts[0].text; const context = parts[0].text;
expect(context).toContain("Today's date is Tuesday, August 5, 2025"); // Use a more flexible date assertion that works with different locales
expect(context).toMatch(/Today's date is .*2025.*/);
expect(context).toContain(`My operating system is: ${process.platform}`); expect(context).toContain(`My operating system is: ${process.platform}`);
expect(context).toContain( expect(context).toContain(
"I'm currently working in the directory: /test/dir", "I'm currently working in the directory: /test/dir",