mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
fix: add patch for sync upstream
This commit is contained in:
42
package-lock.json
generated
42
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ export function AuthDialog({
|
|||||||
|
|
||||||
useKeypress(
|
useKeypress(
|
||||||
(key) => {
|
(key) => {
|
||||||
|
|
||||||
if (showOpenAIKeyPrompt) {
|
if (showOpenAIKeyPrompt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user