diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 78147c3f2a1..044267e511d 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -272,6 +272,10 @@ In addition to a project settings file, a project's `.qwen` directory can contai - **Description:** API key for Tavily web search service. Required to enable the `web_search` tool functionality. If not configured, the web search tool will be disabled and skipped. - **Default:** `undefined` (web search disabled) - **Example:** `"tavilyApiKey": "tvly-your-api-key-here"` +- **`readAfterEdit`** (boolean): + - **Description:** Automatically read file content after editing to provide context to the AI. When enabled, the content of a file is included in the LLM response after successful edit operations, enhancing the AI's awareness of the changes made. + - **Default:** `true` + - **Example:** `"readAfterEdit": false` - **`chatCompression`** (object): - **Description:** Controls the settings for chat history compression, both automatic and when manually invoked through the /compress command. diff --git a/docs/tools/file-system.md b/docs/tools/file-system.md index 45c1eaa7b01..0181614c8cf 100644 --- a/docs/tools/file-system.md +++ b/docs/tools/file-system.md @@ -167,6 +167,7 @@ search_file_content(pattern="function", include="*.js", maxResults=10) - `old_string` is found multiple times, and the self-correction mechanism cannot resolve it to a single, unambiguous match. - **Output (`llmContent`):** - On success: `Successfully modified file: /path/to/file.txt (1 replacements).` or `Created new file: /path/to/new_file.txt with provided content.` + - When the `readAfterEdit` configuration is enabled (default), the updated file content is also included in the response to provide context to the AI. - On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit, expected 1 occurrences but found 2...`). - **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a16ceb0d92e..808f4355cc8 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -585,6 +585,7 @@ export async function loadCliConfig( chatCompression: settings.chatCompression, folderTrustFeature, folderTrust, + readAfterEdit: settings.readAfterEdit ?? true, interactive, trustedFolder, }); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 4a21ebe5bc3..bf3a31975c6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -540,6 +540,16 @@ export const SETTINGS_SCHEMA = { description: 'The API key for the Tavily API.', showInDialog: false, }, + readAfterEdit: { + type: 'boolean', + label: 'Read After Edit', + category: 'Tools', + requiresRestart: false, + default: true, + description: + 'Automatically read file content after editing to provide context to the AI.', + showInDialog: true, + }, } as const; type InferSettings = { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 45d39b439dd..156cdd750c7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -223,6 +223,7 @@ export interface ConfigParameters { chatCompression?: ChatCompressionSettings; interactive?: boolean; trustedFolder?: boolean; + readAfterEdit?: boolean; } export class Config { @@ -303,6 +304,7 @@ export class Config { private readonly chatCompression: ChatCompressionSettings | undefined; private readonly interactive: boolean; private readonly trustedFolder: boolean | undefined; + private readonly readAfterEdit: boolean; private initialized: boolean = false; constructor(params: ConfigParameters) { @@ -380,6 +382,7 @@ export class Config { this.chatCompression = params.chatCompression; this.interactive = params.interactive ?? false; this.trustedFolder = params.trustedFolder; + this.readAfterEdit = params.readAfterEdit ?? true; // Web search this.tavilyApiKey = params.tavilyApiKey; @@ -813,6 +816,10 @@ export class Config { return this.interactive; } + getReadAfterEdit(): boolean { + return this.readAfterEdit; + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir); diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index b2e31fdda14..4c5058c8494 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -81,6 +81,7 @@ describe('EditTool', () => { getGeminiMdFileCount: () => 0, setGeminiMdFileCount: vi.fn(), getToolRegistry: () => ({}) as any, // Minimal mock for ToolRegistry + getReadAfterEdit: () => vi.fn().mockReturnValue(true), } as unknown as Config; // Reset mocks before each test @@ -847,3 +848,289 @@ describe('EditTool', () => { }); }); }); + +describe('EditTool - readAfterEdit', () => { + let tool: EditTool; + let tempDir: string; + let rootDir: string; + let mockConfig: Config; + let geminiClient: any; + + beforeEach(() => { + vi.restoreAllMocks(); + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'edit-tool-readafteredit-test-'), + ); + rootDir = path.join(tempDir, 'root'); + fs.mkdirSync(rootDir); + + geminiClient = { + generateJson: mockGenerateJson, + }; + + mockConfig = { + getGeminiClient: vi.fn().mockReturnValue(geminiClient), + getTargetDir: () => rootDir, + getApprovalMode: vi.fn(), + getWorkspaceContext: () => createMockWorkspaceContext(rootDir), + getReadAfterEdit: vi.fn().mockReturnValue(true), // Default to true for these tests + } as unknown as Config; + + (mockConfig.getApprovalMode as Mock).mockClear(); + (mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.DEFAULT); + + mockEnsureCorrectEdit.mockReset(); + mockEnsureCorrectEdit.mockImplementation( + async (_, currentContent, params) => { + let occurrences = 0; + if (params.old_string && currentContent) { + let index = currentContent.indexOf(params.old_string); + while (index !== -1) { + occurrences++; + index = currentContent.indexOf(params.old_string, index + 1); + } + } else if (params.old_string === '') { + occurrences = 0; + } + return Promise.resolve({ params, occurrences }); + }, + ); + + mockGenerateJson.mockReset(); + mockGenerateJson.mockImplementation(async () => Promise.resolve({})); + + tool = new EditTool(mockConfig); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('readAfterEdit enabled', () => { + beforeEach(() => { + (mockConfig.getReadAfterEdit as Mock).mockReturnValue(true); + }); + + it('should include file content in llmContent after successful edit', async () => { + const testFile = 'test.txt'; + const filePath = path.join(rootDir, testFile); + const initialContent = 'This is the original content.'; + const newContent = 'This is the modified content.'; + + fs.writeFileSync(filePath, initialContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'original', + new_string: 'modified', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch(/Successfully modified file/); + expect(result.llmContent).toContain(newContent); + expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); + }); + + it('should include file content in llmContent when creating a new file', async () => { + const newFileName = 'new_file.txt'; + const newFilePath = path.join(rootDir, newFileName); + const fileContent = 'Content for the new file.'; + + const params: EditToolParams = { + file_path: newFilePath, + old_string: '', + new_string: fileContent, + }; + + (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( + ApprovalMode.AUTO_EDIT, + ); + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch(/Created new file/); + expect(result.llmContent).toContain(fileContent); + expect(fs.existsSync(newFilePath)).toBe(true); + expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent); + }); + + it('should include file content in llmContent when replacing multiple occurrences', async () => { + const testFile = 'test.txt'; + const filePath = path.join(rootDir, testFile); + const initialContent = 'old text old text old text'; + const expectedContent = 'new text new text new text'; + + fs.writeFileSync(filePath, initialContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'old', + new_string: 'new', + expected_replacements: 3, + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch(/Successfully modified file/); + expect(result.llmContent).toContain(expectedContent); + expect(fs.readFileSync(filePath, 'utf8')).toBe(expectedContent); + }); + + it('should include file content even when user modified the new_string', async () => { + const testFile = 'test.txt'; + const filePath = path.join(rootDir, testFile); + const initialContent = 'This is some old text.'; + const newContent = 'This is some new text.'; + + fs.writeFileSync(filePath, initialContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'old', + new_string: 'new', + modified_by_user: true, + }; + + (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( + ApprovalMode.AUTO_EDIT, + ); + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch( + /User modified the `new_string` content/, + ); + expect(result.llmContent).toContain(newContent); + }); + }); + + describe('readAfterEdit disabled', () => { + beforeEach(() => { + (mockConfig.getReadAfterEdit as Mock).mockReturnValue(false); + }); + + it('should NOT include file content in llmContent after successful edit when disabled', async () => { + const testFile = 'test.txt'; + const filePath = path.join(rootDir, testFile); + const initialContent = 'This is the original content.'; + const newContent = 'This is the modified content.'; + + fs.writeFileSync(filePath, initialContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'original', + new_string: 'modified', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch(/Successfully modified file/); + expect(result.llmContent).not.toContain(newContent); + expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); + }); + + it('should NOT include file content when creating a new file and feature is disabled', async () => { + const newFileName = 'new_file.txt'; + const newFilePath = path.join(rootDir, newFileName); + const fileContent = 'Content for the new file.'; + + const params: EditToolParams = { + file_path: newFilePath, + old_string: '', + new_string: fileContent, + }; + + (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( + ApprovalMode.AUTO_EDIT, + ); + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch(/Created new file/); + expect(result.llmContent).not.toContain(fileContent); + expect(fs.existsSync(newFilePath)).toBe(true); + expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent); + }); + + it('should NOT include file content when replacing multiple occurrences and feature is disabled', async () => { + const testFile = 'test.txt'; + const filePath = path.join(rootDir, testFile); + const initialContent = 'old text old text old text'; + const expectedContent = 'new text new text new text'; + + fs.writeFileSync(filePath, initialContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'old', + new_string: 'new', + expected_replacements: 3, + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch(/Successfully modified file/); + expect(result.llmContent).not.toContain(expectedContent); + expect(fs.readFileSync(filePath, 'utf8')).toBe(expectedContent); + }); + }); + + describe('Error cases with readAfterEdit', () => { + beforeEach(() => { + (mockConfig.getReadAfterEdit as Mock).mockReturnValue(true); + }); + + it('should not include file content in llmContent when edit fails', async () => { + const testFile = 'test.txt'; + const filePath = path.join(rootDir, testFile); + const initialContent = 'Some content.'; + + fs.writeFileSync(filePath, initialContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: 'nonexistent', + new_string: 'replacement', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch( + /0 occurrences found for old_string in/, + ); + expect(result.llmContent).not.toContain(initialContent); // Should not include file content on error + expect(fs.readFileSync(filePath, 'utf8')).toBe(initialContent); // File should be unchanged + }); + + it('should not include file content in llmContent when file already exists during creation', async () => { + const testFile = 'test.txt'; + const filePath = path.join(rootDir, testFile); + const existingContent = 'Existing content'; + + fs.writeFileSync(filePath, existingContent, 'utf8'); + + const params: EditToolParams = { + file_path: filePath, + old_string: '', + new_string: 'new content', + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toMatch(/File already exists, cannot create/); + expect(result.llmContent).not.toContain(existingContent); // Should not include file content on error + expect(fs.readFileSync(filePath, 'utf8')).toBe(existingContent); // File should be unchanged + }); + }); +}); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 8d90dfe45d6..bbbe34de235 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -384,8 +384,13 @@ class EditToolInvocation implements ToolInvocation { ); } + let llmContent = llmSuccessMessageParts.join(' '); + if (this.config.getReadAfterEdit()) { + llmContent += `\n${editData.newContent}`; + } + return { - llmContent: llmSuccessMessageParts.join(' '), + llmContent, returnDisplay: displayResult, }; } catch (error) {