Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/cli/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/tools/file-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ export async function loadCliConfig(
chatCompression: settings.chatCompression,
folderTrustFeature,
folderTrust,
readAfterEdit: settings.readAfterEdit ?? true,
interactive,
trustedFolder,
});
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends SettingsSchema> = {
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export interface ConfigParameters {
chatCompression?: ChatCompressionSettings;
interactive?: boolean;
trustedFolder?: boolean;
readAfterEdit?: boolean;
}

export class Config {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -813,6 +816,10 @@ export class Config {
return this.interactive;
}

getReadAfterEdit(): boolean {
return this.readAfterEdit;
}

async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir);
Expand Down
287 changes: 287 additions & 0 deletions packages/core/src/tools/edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
});
});
});
7 changes: 6 additions & 1 deletion packages/core/src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,8 +384,13 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
);
}

let llmContent = llmSuccessMessageParts.join(' ');
if (this.config.getReadAfterEdit()) {
llmContent += `\n${editData.newContent}`;
}

return {
llmContent: llmSuccessMessageParts.join(' '),
llmContent,
returnDisplay: displayResult,
};
} catch (error) {
Expand Down
Loading