From 1e2bbd1be3f854d27f7219aded28ec3da7a7d62b Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 20 Aug 2025 22:24:53 +0800 Subject: [PATCH] fix: add patch for sync upstream --- package-lock.json | 42 ++ packages/cli/src/config/config.test.ts | 11 +- packages/cli/src/config/trustedFolders.ts | 2 +- packages/cli/src/ui/IdeIntegrationNudge.tsx | 2 +- packages/cli/src/ui/components/AuthDialog.tsx | 1 - .../ui/components/EditorSettingsDialog.tsx | 4 +- .../cli/src/ui/hooks/useFolderTrust.test.ts | 2 +- packages/cli/src/ui/hooks/useFolderTrust.ts | 2 +- packages/cli/src/ui/hooks/useKeypress.ts | 2 +- packages/core/src/config/config.test.ts | 16 - packages/core/src/core/contentGenerator.ts | 1 + .../src/core/openaiContentGenerator.test.ts | 4 +- packages/core/src/ide/ide-installer.test.ts | 5 - .../core/src/qwen/qwenContentGenerator.ts | 10 +- packages/core/src/qwen/qwenOAuth2.test.ts | 11 +- packages/core/src/qwen/qwenOAuth2.ts | 5 +- packages/core/src/telemetry/loggers.ts | 39 +- packages/core/src/tools/memoryTool.test.ts | 5 + packages/core/src/tools/memoryTool.ts | 2 +- packages/core/src/tools/shell.test.ts | 364 ++++++++++++------ packages/core/src/tools/shell.ts | 4 +- packages/core/src/tools/web-fetch.ts | 265 +++++++------ .../core/src/utils/environmentContext.test.ts | 12 +- 23 files changed, 508 insertions(+), 303 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6702a654..a9d095d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -979,6 +979,27 @@ "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": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -12550,6 +12571,27 @@ "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": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 78c290b1..0538c1f9 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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', diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts index 9da27c80..e3904113 100644 --- a/packages/cli/src/config/trustedFolders.ts +++ b/packages/cli/src/config/trustedFolders.ts @@ -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'; diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx index 2be69ad7..7f42c600 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -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, diff --git a/packages/cli/src/ui/components/AuthDialog.tsx b/packages/cli/src/ui/components/AuthDialog.tsx index 6bc0131f..f0ff73c5 100644 --- a/packages/cli/src/ui/components/AuthDialog.tsx +++ b/packages/cli/src/ui/components/AuthDialog.tsx @@ -111,7 +111,6 @@ export function AuthDialog({ useKeypress( (key) => { - if (showOpenAIKeyPrompt) { return; } diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index 8732e23b..18ba1826 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -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}`); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index e565ab05..0ece9711 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -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 { diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index 6458d4aa..aa0308ae 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -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'; diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index 920270ee..ec94bf9a 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -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'; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index eecfcbc1..6ebe0aab 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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(); @@ -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', () => { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index ed6a8b66..e58c70b1 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -70,6 +70,7 @@ export type ContentGeneratorConfig = { max_tokens?: number; }; proxy?: string | undefined; + userAgent?: string; }; export function createContentGeneratorConfig( diff --git a/packages/core/src/core/openaiContentGenerator.test.ts b/packages/core/src/core/openaiContentGenerator.test.ts index 92de6235..46c8267e 100644 --- a/packages/core/src/core/openaiContentGenerator.test.ts +++ b/packages/core/src/core/openaiContentGenerator.test.ts @@ -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 } } diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts index 88a677b2..698c3173 100644 --- a/packages/core/src/ide/ide-installer.test.ts +++ b/packages/core/src/ide/ide-installer.test.ts @@ -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(); diff --git a/packages/core/src/qwen/qwenContentGenerator.ts b/packages/core/src/qwen/qwenContentGenerator.ts index 1158d547..f9daf5c0 100644 --- a/packages/core/src/qwen/qwenContentGenerator.ts +++ b/packages/core/src/qwen/qwenContentGenerator.ts @@ -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 { @@ -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> { @@ -127,7 +127,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator { /** * Override to use dynamic token and endpoint */ - async countTokens( + override async countTokens( request: CountTokensParameters, ): Promise { 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 { return this.withValidToken(async (token) => { diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 7d97d51d..73a3a567 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -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(); diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index 0c137702..58692117 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -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; diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index a5ab3566..c887f164 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -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(); diff --git a/packages/core/src/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts index 7eede859..1e236f7e 100644 --- a/packages/core/src/tools/memoryTool.test.ts +++ b/packages/core/src/tools/memoryTool.test.ts @@ -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 () => { diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 492d2330..7b4beed6 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -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( diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 34c6292a..2bd31dad 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -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 ', + ), + 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 ', + ), + 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 ', + ), + 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 ', + ), + 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 ', + ), + 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 "`, - ); - }); - - 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 '`, - ); - }); - - 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 "`, - ); - }); - - 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 "`, - ); - }); - - 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 "`, - ); - }); - }); }); describe('validateToolParams', () => { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index eef7ea3e..fc91d3f3 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -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) { diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 07f00515..60a20f41 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -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 { + 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 { + // 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 { +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 { } } - private async executeFetch( + protected override validateToolParams( params: WebFetchToolParams, - signal: AbortSignal, - ): Promise { - 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 { - 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 { - const validationError = this.validateParams(params); - if (validationError) { - return { - llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, - returnDisplay: validationError, - }; - } - - return this.executeFetch(params, signal); + ): ToolInvocation { + return new WebFetchToolInvocation(this.config, params); } } diff --git a/packages/core/src/utils/environmentContext.test.ts b/packages/core/src/utils/environmentContext.test.ts index 656fb63f..3f4e2f81 100644 --- a/packages/core/src/utils/environmentContext.test.ts +++ b/packages/core/src/utils/environmentContext.test.ts @@ -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",