fix: add patch for sync upstream

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

View File

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

View File

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

View File

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

View File

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

View File

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