Merge branch 'main' into web-search

This commit is contained in:
pomelo-nwu
2025-10-30 19:42:00 +08:00
73 changed files with 1367 additions and 1661 deletions

View File

@@ -131,16 +131,14 @@ describe('ExitPlanModeTool', () => {
}
const result = await invocation.execute(signal);
const expectedLlmMessage =
'User has approved your plan. You can now start coding. Start with updating your todo list if applicable.';
expect(result).toEqual({
llmContent: expectedLlmMessage,
returnDisplay: {
type: 'plan_summary',
message: 'User approved the plan.',
plan: params.plan,
},
expect(result.llmContent).toContain(
'User has approved your plan. You can now start coding',
);
expect(result.returnDisplay).toEqual({
type: 'plan_summary',
message: 'User approved the plan.',
plan: params.plan,
});
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
@@ -188,15 +186,12 @@ describe('ExitPlanModeTool', () => {
const result = await invocation.execute(signal);
expect(result).toEqual({
llmContent: JSON.stringify({
success: false,
plan: params.plan,
error: 'Plan execution was not approved. Remaining in plan mode.',
}),
returnDisplay:
'Plan execution was not approved. Remaining in plan mode.',
});
expect(result.llmContent).toBe(
'Plan execution was not approved. Remaining in plan mode.',
);
expect(result.returnDisplay).toBe(
'Plan execution was not approved. Remaining in plan mode.',
);
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.PLAN,
@@ -215,50 +210,6 @@ describe('ExitPlanModeTool', () => {
);
});
it('should handle execution errors gracefully', async () => {
const params: ExitPlanModeParams = {
plan: 'Test plan',
};
const invocation = tool.build(params);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
if (confirmation) {
// Don't approve the plan so we go through the rejection path
await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);
}
// Create a spy to simulate an error during the execution
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Mock JSON.stringify to throw an error in the rejection path
const originalStringify = JSON.stringify;
vi.spyOn(JSON, 'stringify').mockImplementationOnce(() => {
throw new Error('JSON stringify error');
});
const result = await invocation.execute(new AbortController().signal);
expect(result).toEqual({
llmContent: JSON.stringify({
success: false,
error: 'Failed to present plan. Detail: JSON stringify error',
}),
returnDisplay: 'Error presenting plan: JSON stringify error',
});
expect(consoleSpy).toHaveBeenCalledWith(
'[ExitPlanModeTool] Error executing exit_plan_mode: JSON stringify error',
);
// Restore original JSON.stringify
JSON.stringify = originalStringify;
consoleSpy.mockRestore();
});
it('should return empty tool locations', () => {
const params: ExitPlanModeParams = {
plan: 'Test plan',

View File

@@ -115,17 +115,12 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
const rejectionMessage =
'Plan execution was not approved. Remaining in plan mode.';
return {
llmContent: JSON.stringify({
success: false,
plan,
error: rejectionMessage,
}),
llmContent: rejectionMessage,
returnDisplay: rejectionMessage,
};
}
const llmMessage =
'User has approved your plan. You can now start coding. Start with updating your todo list if applicable.';
const llmMessage = `User has approved your plan. You can now start coding. Start with updating your todo list if applicable.`;
const displayMessage = 'User approved the plan.';
return {
@@ -142,11 +137,11 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
console.error(
`[ExitPlanModeTool] Error executing exit_plan_mode: ${errorMessage}`,
);
const errorLlmContent = `Failed to present plan: ${errorMessage}`;
return {
llmContent: JSON.stringify({
success: false,
error: `Failed to present plan. Detail: ${errorMessage}`,
}),
llmContent: errorLlmContent,
returnDisplay: `Error presenting plan: ${errorMessage}`,
};
}

View File

@@ -241,9 +241,7 @@ describe('MemoryTool', () => {
expectedFsArgument,
);
const successMessage = `Okay, I've remembered that in global memory: "${params.fact}"`;
expect(result.llmContent).toBe(
JSON.stringify({ success: true, message: successMessage }),
);
expect(result.llmContent).toBe(successMessage);
expect(result.returnDisplay).toBe(successMessage);
});
@@ -271,9 +269,7 @@ describe('MemoryTool', () => {
expectedFsArgument,
);
const successMessage = `Okay, I've remembered that in project memory: "${params.fact}"`;
expect(result.llmContent).toBe(
JSON.stringify({ success: true, message: successMessage }),
);
expect(result.llmContent).toBe(successMessage);
expect(result.returnDisplay).toBe(successMessage);
});
@@ -298,10 +294,7 @@ describe('MemoryTool', () => {
const result = await invocation.execute(mockAbortSignal);
expect(result.llmContent).toBe(
JSON.stringify({
success: false,
error: `Failed to save memory. Detail: ${underlyingError.message}`,
}),
`Error saving memory: ${underlyingError.message}`,
);
expect(result.returnDisplay).toBe(
`Error saving memory: ${underlyingError.message}`,
@@ -319,6 +312,8 @@ describe('MemoryTool', () => {
expect(result.llmContent).toContain(
'Please specify where to save this memory',
);
expect(result.llmContent).toContain('Global:');
expect(result.llmContent).toContain('Project:');
expect(result.returnDisplay).toContain('Global:');
expect(result.returnDisplay).toContain('Project:');
});

View File

@@ -309,7 +309,7 @@ Preview of changes to be made to GLOBAL memory:
if (!fact || typeof fact !== 'string' || fact.trim() === '') {
const errorMessage = 'Parameter "fact" must be a non-empty string.';
return {
llmContent: JSON.stringify({ success: false, error: errorMessage }),
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
};
}
@@ -324,10 +324,7 @@ Global: ${globalPath} (shared across all projects)
Project: ${projectPath} (current project only)`;
return {
llmContent: JSON.stringify({
success: false,
error: 'Please specify where to save this memory',
}),
llmContent: errorMessage,
returnDisplay: errorMessage,
};
}
@@ -344,10 +341,7 @@ Project: ${projectPath} (current project only)`;
await fs.writeFile(memoryFilePath, modified_content, 'utf-8');
const successMessage = `Okay, I've updated the ${scope} memory file with your modifications.`;
return {
llmContent: JSON.stringify({
success: true,
message: successMessage,
}),
llmContent: successMessage,
returnDisplay: successMessage,
};
} else {
@@ -359,10 +353,7 @@ Project: ${projectPath} (current project only)`;
});
const successMessage = `Okay, I've remembered that in ${scope} memory: "${fact}"`;
return {
llmContent: JSON.stringify({
success: true,
message: successMessage,
}),
llmContent: successMessage,
returnDisplay: successMessage,
};
}
@@ -372,11 +363,9 @@ Project: ${projectPath} (current project only)`;
console.error(
`[MemoryTool] Error executing save_memory for fact "${fact}" in ${scope}: ${errorMessage}`,
);
return {
llmContent: JSON.stringify({
success: false,
error: `Failed to save memory. Detail: ${errorMessage}`,
}),
llmContent: `Error saving memory: ${errorMessage}`,
returnDisplay: `Error saving memory: ${errorMessage}`,
error: {
message: errorMessage,

View File

@@ -14,7 +14,7 @@ import {
type Mock,
} from 'vitest';
import type { RipGrepToolParams } from './ripGrep.js';
import { canUseRipgrep, RipGrepTool, ensureRgPath } from './ripGrep.js';
import { RipGrepTool } from './ripGrep.js';
import path from 'node:path';
import fs from 'node:fs/promises';
import os, { EOL } from 'node:os';
@@ -22,24 +22,11 @@ import type { Config } from '../config/config.js';
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import { fileExists } from '../utils/fileUtils.js';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
// Mock dependencies for canUseRipgrep
vi.mock('@joshua.litt/get-ripgrep', () => ({
downloadRipGrep: vi.fn(),
}));
vi.mock('../utils/fileUtils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/fileUtils.js')>();
return {
...actual,
fileExists: vi.fn(),
};
});
vi.mock('../config/storage.js', () => ({
Storage: {
getGlobalBinDir: vi.fn().mockReturnValue('/mock/bin/dir'),
},
// Mock ripgrepUtils
vi.mock('../utils/ripgrepUtils.js', () => ({
ensureRipgrepPath: vi.fn(),
}));
// Mock child_process for ripgrep calls
@@ -49,97 +36,6 @@ vi.mock('child_process', () => ({
const mockSpawn = vi.mocked(spawn);
describe('canUseRipgrep', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return true if ripgrep already exists', async () => {
(fileExists as Mock).mockResolvedValue(true);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledWith(path.join('/mock/bin/dir', 'rg'));
expect(downloadRipGrep).not.toHaveBeenCalled();
});
it('should download ripgrep and return true if it does not exist initially', async () => {
(fileExists as Mock)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
const result = await canUseRipgrep();
expect(result).toBe(true);
expect(fileExists).toHaveBeenCalledTimes(2);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
it('should return false if download fails and file does not exist', async () => {
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
const result = await canUseRipgrep();
expect(result).toBe(false);
expect(fileExists).toHaveBeenCalledTimes(2);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
it('should propagate errors from downloadRipGrep', async () => {
const error = new Error('Download failed');
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockRejectedValue(error);
await expect(canUseRipgrep()).rejects.toThrow(error);
expect(fileExists).toHaveBeenCalledTimes(1);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
});
describe('ensureRgPath', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return rg path if ripgrep already exists', async () => {
(fileExists as Mock).mockResolvedValue(true);
const rgPath = await ensureRgPath();
expect(rgPath).toBe(path.join('/mock/bin/dir', 'rg'));
expect(fileExists).toHaveBeenCalledOnce();
expect(downloadRipGrep).not.toHaveBeenCalled();
});
it('should return rg path if ripgrep is downloaded successfully', async () => {
(fileExists as Mock)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
const rgPath = await ensureRgPath();
expect(rgPath).toBe(path.join('/mock/bin/dir', 'rg'));
expect(downloadRipGrep).toHaveBeenCalledOnce();
expect(fileExists).toHaveBeenCalledTimes(2);
});
it('should throw an error if ripgrep cannot be used after download attempt', async () => {
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
await expect(ensureRgPath()).rejects.toThrow('Cannot use ripgrep.');
expect(downloadRipGrep).toHaveBeenCalledOnce();
expect(fileExists).toHaveBeenCalledTimes(2);
});
it('should propagate errors from downloadRipGrep', async () => {
const error = new Error('Download failed');
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockRejectedValue(error);
await expect(ensureRgPath()).rejects.toThrow(error);
expect(fileExists).toHaveBeenCalledTimes(1);
expect(downloadRipGrep).toHaveBeenCalledWith('/mock/bin/dir');
});
});
// Helper function to create mock spawn implementations
function createMockSpawn(
options: {
@@ -201,8 +97,7 @@ describe('RipGrepTool', () => {
beforeEach(async () => {
vi.clearAllMocks();
(downloadRipGrep as Mock).mockResolvedValue(undefined);
(fileExists as Mock).mockResolvedValue(true);
(ensureRipgrepPath as Mock).mockResolvedValue('/mock/path/to/rg');
mockSpawn.mockClear();
tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-'));
grepTool = new RipGrepTool(mockConfig);
@@ -551,16 +446,18 @@ describe('RipGrepTool', () => {
});
it('should throw an error if ripgrep is not available', async () => {
// Make ensureRgPath throw
(fileExists as Mock).mockResolvedValue(false);
(downloadRipGrep as Mock).mockResolvedValue(undefined);
// Make ensureRipgrepBinary throw
(ensureRipgrepPath as Mock).mockRejectedValue(
new Error('Ripgrep binary not found'),
);
const params: RipGrepToolParams = { pattern: 'world' };
const invocation = grepTool.build(params);
expect(await invocation.execute(abortSignal)).toStrictEqual({
llmContent: 'Error during grep search operation: Cannot use ripgrep.',
returnDisplay: 'Error: Cannot use ripgrep.',
llmContent:
'Error during grep search operation: Ripgrep binary not found',
returnDisplay: 'Error: Ripgrep binary not found',
});
});
});

View File

@@ -8,44 +8,16 @@ import fs from 'node:fs';
import path from 'node:path';
import { EOL } from 'node:os';
import { spawn } from 'node:child_process';
import { downloadRipGrep } from '@joshua.litt/get-ripgrep';
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js';
import { getErrorMessage, isNodeError } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { fileExists } from '../utils/fileUtils.js';
import { Storage } from '../config/storage.js';
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
const DEFAULT_TOTAL_MAX_MATCHES = 20000;
function getRgPath(): string {
return path.join(Storage.getGlobalBinDir(), 'rg');
}
/**
* Checks if `rg` exists, if not then attempt to download it.
*/
export async function canUseRipgrep(): Promise<boolean> {
if (await fileExists(getRgPath())) {
return true;
}
await downloadRipGrep(Storage.getGlobalBinDir());
return await fileExists(getRgPath());
}
/**
* Ensures `rg` is downloaded, or throws.
*/
export async function ensureRgPath(): Promise<string> {
if (await canUseRipgrep()) {
return getRgPath();
}
throw new Error('Cannot use ripgrep.');
}
/**
* Parameters for the GrepTool
*/
@@ -320,7 +292,7 @@ class GrepToolInvocation extends BaseToolInvocation<
rgArgs.push(absolutePath);
try {
const rgPath = await ensureRgPath();
const rgPath = await ensureRipgrepPath();
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(rgPath, rgArgs, {
windowsHide: true,
@@ -342,11 +314,7 @@ class GrepToolInvocation extends BaseToolInvocation<
child.on('error', (err) => {
options.signal.removeEventListener('abort', cleanup);
reject(
new Error(
`Failed to start ripgrep: ${err.message}. Please ensure @lvce-editor/ripgrep is properly installed.`,
),
);
reject(new Error(`Failed to start ripgrep: ${err.message}.`));
});
child.on('close', (code) => {

View File

@@ -141,7 +141,12 @@ describe('TodoWriteTool', () => {
const invocation = tool.build(params);
const result = await invocation.execute(mockAbortSignal);
expect(result.llmContent).toContain('success');
expect(result.llmContent).toContain(
'Todos have been modified successfully',
);
expect(result.llmContent).toContain('<system-reminder>');
expect(result.llmContent).toContain('Your todo list has changed');
expect(result.llmContent).toContain(JSON.stringify(params.todos));
expect(result.returnDisplay).toEqual({
type: 'todo_list',
todos: [
@@ -178,7 +183,12 @@ describe('TodoWriteTool', () => {
const invocation = tool.build(params);
const result = await invocation.execute(mockAbortSignal);
expect(result.llmContent).toContain('success');
expect(result.llmContent).toContain(
'Todos have been modified successfully',
);
expect(result.llmContent).toContain('<system-reminder>');
expect(result.llmContent).toContain('Your todo list has changed');
expect(result.llmContent).toContain(JSON.stringify(params.todos));
expect(result.returnDisplay).toEqual({
type: 'todo_list',
todos: [
@@ -208,7 +218,10 @@ describe('TodoWriteTool', () => {
const invocation = tool.build(params);
const result = await invocation.execute(mockAbortSignal);
expect(result.llmContent).toContain('"success":false');
expect(result.llmContent).toContain('Failed to modify todos');
expect(result.llmContent).toContain('<system-reminder>');
expect(result.llmContent).toContain('Todo list modification failed');
expect(result.llmContent).toContain('Write failed');
expect(result.returnDisplay).toContain('Error writing todos');
});
@@ -223,7 +236,10 @@ describe('TodoWriteTool', () => {
const invocation = tool.build(params);
const result = await invocation.execute(mockAbortSignal);
expect(result.llmContent).toContain('success');
expect(result.llmContent).toContain('Todo list has been cleared');
expect(result.llmContent).toContain('<system-reminder>');
expect(result.llmContent).toContain('Your todo list is now empty');
expect(result.llmContent).toContain('no pending tasks');
expect(result.returnDisplay).toEqual({
type: 'todo_list',
todos: [],

View File

@@ -340,11 +340,30 @@ class TodoWriteToolInvocation extends BaseToolInvocation<
todos: finalTodos,
};
// Create plain string format with system reminder
const todosJson = JSON.stringify(finalTodos);
let llmContent: string;
if (finalTodos.length === 0) {
// Special message for empty todos
llmContent = `Todo list has been cleared.
<system-reminder>
Your todo list is now empty. DO NOT mention this explicitly to the user. You have no pending tasks in your todo list.
</system-reminder>`;
} else {
// Normal message for todos with items
llmContent = `Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable
<system-reminder>
Your todo list has changed. DO NOT mention this explicitly to the user. Here are the latest contents of your todo list:
${todosJson}. Continue on with the tasks at hand if applicable.
</system-reminder>`;
}
return {
llmContent: JSON.stringify({
success: true,
todos: finalTodos,
}),
llmContent,
returnDisplay: todoResultDisplay,
};
} catch (error) {
@@ -353,11 +372,16 @@ class TodoWriteToolInvocation extends BaseToolInvocation<
console.error(
`[TodoWriteTool] Error executing todo_write: ${errorMessage}`,
);
// Create plain string format for error with system reminder
const errorLlmContent = `Failed to modify todos. An error occurred during the operation.
<system-reminder>
Todo list modification failed with error: ${errorMessage}. You may need to retry or handle this error appropriately.
</system-reminder>`;
return {
llmContent: JSON.stringify({
success: false,
error: `Failed to write todos. Detail: ${errorMessage}`,
}),
llmContent: errorLlmContent,
returnDisplay: `Error writing todos: ${errorMessage}`,
};
}