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

View File

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

View File

@@ -7,7 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
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';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';

View File

@@ -4,7 +4,7 @@
* 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 {
RadioButtonSelect,

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
import { vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
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 { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {

View File

@@ -5,7 +5,7 @@
*/
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 { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,11 +24,6 @@ describe('ide-installer', () => {
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', () => {
const installer = getIdeInstaller('unknown' as DetectedIde);
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
*/
protected shouldSuppressErrorLogging(
protected override shouldSuppressErrorLogging(
error: unknown,
_request: GenerateContentParameters,
): boolean {
@@ -76,7 +76,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override to use dynamic token and endpoint
*/
async generateContent(
override async generateContent(
request: GenerateContentParameters,
userPromptId: string,
): Promise<GenerateContentResponse> {
@@ -100,7 +100,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override to use dynamic token and endpoint
*/
async generateContentStream(
override async generateContentStream(
request: GenerateContentParameters,
userPromptId: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
@@ -127,7 +127,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override to use dynamic token and endpoint
*/
async countTokens(
override async countTokens(
request: CountTokensParameters,
): Promise<CountTokensResponse> {
return this.withValidToken(async (token) => {
@@ -148,7 +148,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override to use dynamic token and endpoint
*/
async embedContent(
override async embedContent(
request: EmbedContentParameters,
): Promise<EmbedContentResponse> {
return this.withValidToken(async (token) => {

View File

@@ -223,17 +223,9 @@ describe('Type Guards', () => {
describe('QwenOAuth2Client', () => {
let client: QwenOAuth2Client;
let _mockConfig: Config;
let originalFetch: typeof global.fetch;
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
client = new QwenOAuth2Client({ proxy: undefined });
@@ -1010,7 +1002,6 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
let mockConfig: Config;
let originalFetch: typeof global.fetch;
let _client: QwenOAuth2Client;
beforeEach(() => {
mockConfig = {
@@ -1018,7 +1009,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
} as unknown as Config;
_client = new QwenOAuth2Client({ proxy: undefined });
new QwenOAuth2Client({ proxy: undefined });
originalFetch = global.fetch;
global.fetch = vi.fn();

View File

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

View File

@@ -4,46 +4,47 @@
* 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 { Config } from '../config/config.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import {
EVENT_API_ERROR,
EVENT_API_REQUEST,
EVENT_API_RESPONSE,
EVENT_CLI_CONFIG,
EVENT_FLASH_FALLBACK,
EVENT_IDE_CONNECTION,
EVENT_NEXT_SPEAKER_CHECK,
EVENT_SLASH_COMMAND,
EVENT_TOOL_CALL,
EVENT_USER_PROMPT,
EVENT_FLASH_FALLBACK,
EVENT_NEXT_SPEAKER_CHECK,
SERVICE_NAME,
EVENT_SLASH_COMMAND,
} 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 {
ApiErrorEvent,
ApiRequestEvent,
ApiResponseEvent,
FlashFallbackEvent,
IdeConnectionEvent,
KittySequenceOverflowEvent,
LoopDetectedEvent,
NextSpeakerCheckEvent,
SlashCommandEvent,
StartSessionEvent,
ToolCallEvent,
UserPromptEvent,
FlashFallbackEvent,
NextSpeakerCheckEvent,
LoopDetectedEvent,
SlashCommandEvent,
KittySequenceOverflowEvent,
} from './types.js';
import {
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';
import { UiEvent, uiTelemetryService } from './uiTelemetry.js';
const shouldLogUserPrompts = (config: Config): boolean =>
config.getTelemetryLogPromptsEnabled();

View File

@@ -314,6 +314,11 @@ describe('MemoryTool', () => {
memoryTool = new MemoryTool();
// Mock fs.readFile to return empty string (file doesn't exist)
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 () => {

View File

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

View File

@@ -366,6 +366,253 @@ describe('ShellTool', () => {
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', () => {
@@ -396,123 +643,6 @@ describe('ShellTool', () => {
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', () => {

View File

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

View File

@@ -6,15 +6,18 @@
import { SchemaValidator } from '../utils/schemaValidator.js';
import {
BaseTool,
ToolResult,
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
Icon,
ToolInvocation,
ToolResult,
} from './tools.js';
import { Config, ApprovalMode } from '../config/config.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 { ProxyAgent, setGlobalDispatcher } from 'undici';
@@ -35,18 +38,158 @@ export interface WebFetchToolParams {
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
*/
export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
export class WebFetchTool extends BaseDeclarativeTool<
WebFetchToolParams,
ToolResult
> {
static readonly Name: string = 'web_fetch';
constructor(private readonly config: Config) {
super(
WebFetchTool.Name,
'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',
Icon.Globe,
'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',
Kind.Fetch,
{
properties: {
url: {
@@ -68,64 +211,9 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
}
}
private async executeFetch(
protected override validateToolParams(
params: WebFetchToolParams,
signal: AbortSignal,
): 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 {
): string | null {
const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,
@@ -148,52 +236,9 @@ ${textContent}
return null;
}
getDescription(params: WebFetchToolParams): string {
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(
protected createInvocation(
params: WebFetchToolParams,
): Promise<ToolCallConfirmationDetails | false> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
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);
): ToolInvocation<WebFetchToolParams, ToolResult> {
return new WebFetchToolInvocation(this.config, params);
}
}

View File

@@ -79,6 +79,14 @@ describe('getEnvironmentContext', () => {
vi.useFakeTimers();
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 = {
getTool: vi.fn(),
};
@@ -97,6 +105,7 @@ describe('getEnvironmentContext', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.resetAllMocks();
});
@@ -106,7 +115,8 @@ describe('getEnvironmentContext', () => {
expect(parts.length).toBe(1);
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(
"I'm currently working in the directory: /test/dir",