Skip to content

Commit d29967e

Browse files
author
zir
committed
feat(tools): Include the new content after edits
Introduces a new configuration setting, `readAfterEdit`, which is enabled by default. When this setting is active, the `edit` tool will automatically append the full content of a file to its response message (`llmContent`) after a successful modification or creation. This provides the AI with immediate context of the changes, improving its awareness of the file's current state and reducing the need for a subsequent `read_file` call.
1 parent 5cc6569 commit d29967e

File tree

7 files changed

+316
-1
lines changed

7 files changed

+316
-1
lines changed

docs/cli/configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,10 @@ In addition to a project settings file, a project's `.qwen` directory can contai
272272
- **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.
273273
- **Default:** `undefined` (web search disabled)
274274
- **Example:** `"tavilyApiKey": "tvly-your-api-key-here"`
275+
- **`readAfterEdit`** (boolean):
276+
- **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.
277+
- **Default:** `true`
278+
- **Example:** `"readAfterEdit": false`
275279
- **`chatCompression`** (object):
276280
- **Description:** Controls the settings for chat history compression, both automatic and
277281
when manually invoked through the /compress command.

docs/tools/file-system.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ search_file_content(pattern="function", include="*.js", maxResults=10)
167167
- `old_string` is found multiple times, and the self-correction mechanism cannot resolve it to a single, unambiguous match.
168168
- **Output (`llmContent`):**
169169
- On success: `Successfully modified file: /path/to/file.txt (1 replacements).` or `Created new file: /path/to/new_file.txt with provided content.`
170+
- When the `readAfterEdit` configuration is enabled (default), the updated file content is also included in the response to provide context to the AI.
170171
- 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...`).
171172
- **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file.
172173

packages/cli/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ export async function loadCliConfig(
584584
chatCompression: settings.chatCompression,
585585
folderTrustFeature,
586586
folderTrust,
587+
readAfterEdit: settings.readAfterEdit ?? true,
587588
interactive,
588589
trustedFolder,
589590
});

packages/cli/src/config/settingsSchema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,16 @@ export const SETTINGS_SCHEMA = {
540540
description: 'The API key for the Tavily API.',
541541
showInDialog: false,
542542
},
543+
readAfterEdit: {
544+
type: 'boolean',
545+
label: 'Read After Edit',
546+
category: 'Tools',
547+
requiresRestart: false,
548+
default: true,
549+
description:
550+
'Automatically read file content after editing to provide context to the AI.',
551+
showInDialog: true,
552+
},
543553
} as const;
544554

545555
type InferSettings<T extends SettingsSchema> = {

packages/core/src/config/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ export interface ConfigParameters {
222222
chatCompression?: ChatCompressionSettings;
223223
interactive?: boolean;
224224
trustedFolder?: boolean;
225+
readAfterEdit?: boolean;
225226
}
226227

227228
export class Config {
@@ -301,6 +302,7 @@ export class Config {
301302
private readonly chatCompression: ChatCompressionSettings | undefined;
302303
private readonly interactive: boolean;
303304
private readonly trustedFolder: boolean | undefined;
305+
private readonly readAfterEdit: boolean;
304306
private initialized: boolean = false;
305307

306308
constructor(params: ConfigParameters) {
@@ -377,6 +379,7 @@ export class Config {
377379
this.chatCompression = params.chatCompression;
378380
this.interactive = params.interactive ?? false;
379381
this.trustedFolder = params.trustedFolder;
382+
this.readAfterEdit = params.readAfterEdit ?? true;
380383

381384
// Web search
382385
this.tavilyApiKey = params.tavilyApiKey;
@@ -803,6 +806,10 @@ export class Config {
803806
return this.interactive;
804807
}
805808

809+
getReadAfterEdit(): boolean {
810+
return this.readAfterEdit;
811+
}
812+
806813
async getGitService(): Promise<GitService> {
807814
if (!this.gitService) {
808815
this.gitService = new GitService(this.targetDir);

packages/core/src/tools/edit.test.ts

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ describe('EditTool', () => {
8181
getGeminiMdFileCount: () => 0,
8282
setGeminiMdFileCount: vi.fn(),
8383
getToolRegistry: () => ({}) as any, // Minimal mock for ToolRegistry
84+
getReadAfterEdit: () => vi.fn().mockReturnValue(true),
8485
} as unknown as Config;
8586

8687
// Reset mocks before each test
@@ -847,3 +848,289 @@ describe('EditTool', () => {
847848
});
848849
});
849850
});
851+
852+
describe('EditTool - readAfterEdit', () => {
853+
let tool: EditTool;
854+
let tempDir: string;
855+
let rootDir: string;
856+
let mockConfig: Config;
857+
let geminiClient: any;
858+
859+
beforeEach(() => {
860+
vi.restoreAllMocks();
861+
tempDir = fs.mkdtempSync(
862+
path.join(os.tmpdir(), 'edit-tool-readafteredit-test-'),
863+
);
864+
rootDir = path.join(tempDir, 'root');
865+
fs.mkdirSync(rootDir);
866+
867+
geminiClient = {
868+
generateJson: mockGenerateJson,
869+
};
870+
871+
mockConfig = {
872+
getGeminiClient: vi.fn().mockReturnValue(geminiClient),
873+
getTargetDir: () => rootDir,
874+
getApprovalMode: vi.fn(),
875+
getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
876+
getReadAfterEdit: vi.fn().mockReturnValue(true), // Default to true for these tests
877+
} as unknown as Config;
878+
879+
(mockConfig.getApprovalMode as Mock).mockClear();
880+
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.DEFAULT);
881+
882+
mockEnsureCorrectEdit.mockReset();
883+
mockEnsureCorrectEdit.mockImplementation(
884+
async (_, currentContent, params) => {
885+
let occurrences = 0;
886+
if (params.old_string && currentContent) {
887+
let index = currentContent.indexOf(params.old_string);
888+
while (index !== -1) {
889+
occurrences++;
890+
index = currentContent.indexOf(params.old_string, index + 1);
891+
}
892+
} else if (params.old_string === '') {
893+
occurrences = 0;
894+
}
895+
return Promise.resolve({ params, occurrences });
896+
},
897+
);
898+
899+
mockGenerateJson.mockReset();
900+
mockGenerateJson.mockImplementation(async () => Promise.resolve({}));
901+
902+
tool = new EditTool(mockConfig);
903+
});
904+
905+
afterEach(() => {
906+
fs.rmSync(tempDir, { recursive: true, force: true });
907+
});
908+
909+
describe('readAfterEdit enabled', () => {
910+
beforeEach(() => {
911+
(mockConfig.getReadAfterEdit as Mock).mockReturnValue(true);
912+
});
913+
914+
it('should include file content in llmContent after successful edit', async () => {
915+
const testFile = 'test.txt';
916+
const filePath = path.join(rootDir, testFile);
917+
const initialContent = 'This is the original content.';
918+
const newContent = 'This is the modified content.';
919+
920+
fs.writeFileSync(filePath, initialContent, 'utf8');
921+
922+
const params: EditToolParams = {
923+
file_path: filePath,
924+
old_string: 'original',
925+
new_string: 'modified',
926+
};
927+
928+
const invocation = tool.build(params);
929+
const result = await invocation.execute(new AbortController().signal);
930+
931+
expect(result.llmContent).toMatch(/Successfully modified file/);
932+
expect(result.llmContent).toContain(newContent);
933+
expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent);
934+
});
935+
936+
it('should include file content in llmContent when creating a new file', async () => {
937+
const newFileName = 'new_file.txt';
938+
const newFilePath = path.join(rootDir, newFileName);
939+
const fileContent = 'Content for the new file.';
940+
941+
const params: EditToolParams = {
942+
file_path: newFilePath,
943+
old_string: '',
944+
new_string: fileContent,
945+
};
946+
947+
(mockConfig.getApprovalMode as Mock).mockReturnValueOnce(
948+
ApprovalMode.AUTO_EDIT,
949+
);
950+
951+
const invocation = tool.build(params);
952+
const result = await invocation.execute(new AbortController().signal);
953+
954+
expect(result.llmContent).toMatch(/Created new file/);
955+
expect(result.llmContent).toContain(fileContent);
956+
expect(fs.existsSync(newFilePath)).toBe(true);
957+
expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent);
958+
});
959+
960+
it('should include file content in llmContent when replacing multiple occurrences', async () => {
961+
const testFile = 'test.txt';
962+
const filePath = path.join(rootDir, testFile);
963+
const initialContent = 'old text old text old text';
964+
const expectedContent = 'new text new text new text';
965+
966+
fs.writeFileSync(filePath, initialContent, 'utf8');
967+
968+
const params: EditToolParams = {
969+
file_path: filePath,
970+
old_string: 'old',
971+
new_string: 'new',
972+
expected_replacements: 3,
973+
};
974+
975+
const invocation = tool.build(params);
976+
const result = await invocation.execute(new AbortController().signal);
977+
978+
expect(result.llmContent).toMatch(/Successfully modified file/);
979+
expect(result.llmContent).toContain(expectedContent);
980+
expect(fs.readFileSync(filePath, 'utf8')).toBe(expectedContent);
981+
});
982+
983+
it('should include file content even when user modified the new_string', async () => {
984+
const testFile = 'test.txt';
985+
const filePath = path.join(rootDir, testFile);
986+
const initialContent = 'This is some old text.';
987+
const newContent = 'This is some new text.';
988+
989+
fs.writeFileSync(filePath, initialContent, 'utf8');
990+
991+
const params: EditToolParams = {
992+
file_path: filePath,
993+
old_string: 'old',
994+
new_string: 'new',
995+
modified_by_user: true,
996+
};
997+
998+
(mockConfig.getApprovalMode as Mock).mockReturnValueOnce(
999+
ApprovalMode.AUTO_EDIT,
1000+
);
1001+
1002+
const invocation = tool.build(params);
1003+
const result = await invocation.execute(new AbortController().signal);
1004+
1005+
expect(result.llmContent).toMatch(
1006+
/User modified the `new_string` content/,
1007+
);
1008+
expect(result.llmContent).toContain(newContent);
1009+
});
1010+
});
1011+
1012+
describe('readAfterEdit disabled', () => {
1013+
beforeEach(() => {
1014+
(mockConfig.getReadAfterEdit as Mock).mockReturnValue(false);
1015+
});
1016+
1017+
it('should NOT include file content in llmContent after successful edit when disabled', async () => {
1018+
const testFile = 'test.txt';
1019+
const filePath = path.join(rootDir, testFile);
1020+
const initialContent = 'This is the original content.';
1021+
const newContent = 'This is the modified content.';
1022+
1023+
fs.writeFileSync(filePath, initialContent, 'utf8');
1024+
1025+
const params: EditToolParams = {
1026+
file_path: filePath,
1027+
old_string: 'original',
1028+
new_string: 'modified',
1029+
};
1030+
1031+
const invocation = tool.build(params);
1032+
const result = await invocation.execute(new AbortController().signal);
1033+
1034+
expect(result.llmContent).toMatch(/Successfully modified file/);
1035+
expect(result.llmContent).not.toContain(newContent);
1036+
expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent);
1037+
});
1038+
1039+
it('should NOT include file content when creating a new file and feature is disabled', async () => {
1040+
const newFileName = 'new_file.txt';
1041+
const newFilePath = path.join(rootDir, newFileName);
1042+
const fileContent = 'Content for the new file.';
1043+
1044+
const params: EditToolParams = {
1045+
file_path: newFilePath,
1046+
old_string: '',
1047+
new_string: fileContent,
1048+
};
1049+
1050+
(mockConfig.getApprovalMode as Mock).mockReturnValueOnce(
1051+
ApprovalMode.AUTO_EDIT,
1052+
);
1053+
1054+
const invocation = tool.build(params);
1055+
const result = await invocation.execute(new AbortController().signal);
1056+
1057+
expect(result.llmContent).toMatch(/Created new file/);
1058+
expect(result.llmContent).not.toContain(fileContent);
1059+
expect(fs.existsSync(newFilePath)).toBe(true);
1060+
expect(fs.readFileSync(newFilePath, 'utf8')).toBe(fileContent);
1061+
});
1062+
1063+
it('should NOT include file content when replacing multiple occurrences and feature is disabled', async () => {
1064+
const testFile = 'test.txt';
1065+
const filePath = path.join(rootDir, testFile);
1066+
const initialContent = 'old text old text old text';
1067+
const expectedContent = 'new text new text new text';
1068+
1069+
fs.writeFileSync(filePath, initialContent, 'utf8');
1070+
1071+
const params: EditToolParams = {
1072+
file_path: filePath,
1073+
old_string: 'old',
1074+
new_string: 'new',
1075+
expected_replacements: 3,
1076+
};
1077+
1078+
const invocation = tool.build(params);
1079+
const result = await invocation.execute(new AbortController().signal);
1080+
1081+
expect(result.llmContent).toMatch(/Successfully modified file/);
1082+
expect(result.llmContent).not.toContain(expectedContent);
1083+
expect(fs.readFileSync(filePath, 'utf8')).toBe(expectedContent);
1084+
});
1085+
});
1086+
1087+
describe('Error cases with readAfterEdit', () => {
1088+
beforeEach(() => {
1089+
(mockConfig.getReadAfterEdit as Mock).mockReturnValue(true);
1090+
});
1091+
1092+
it('should not include file content in llmContent when edit fails', async () => {
1093+
const testFile = 'test.txt';
1094+
const filePath = path.join(rootDir, testFile);
1095+
const initialContent = 'Some content.';
1096+
1097+
fs.writeFileSync(filePath, initialContent, 'utf8');
1098+
1099+
const params: EditToolParams = {
1100+
file_path: filePath,
1101+
old_string: 'nonexistent',
1102+
new_string: 'replacement',
1103+
};
1104+
1105+
const invocation = tool.build(params);
1106+
const result = await invocation.execute(new AbortController().signal);
1107+
1108+
expect(result.llmContent).toMatch(
1109+
/0 occurrences found for old_string in/,
1110+
);
1111+
expect(result.llmContent).not.toContain(initialContent); // Should not include file content on error
1112+
expect(fs.readFileSync(filePath, 'utf8')).toBe(initialContent); // File should be unchanged
1113+
});
1114+
1115+
it('should not include file content in llmContent when file already exists during creation', async () => {
1116+
const testFile = 'test.txt';
1117+
const filePath = path.join(rootDir, testFile);
1118+
const existingContent = 'Existing content';
1119+
1120+
fs.writeFileSync(filePath, existingContent, 'utf8');
1121+
1122+
const params: EditToolParams = {
1123+
file_path: filePath,
1124+
old_string: '',
1125+
new_string: 'new content',
1126+
};
1127+
1128+
const invocation = tool.build(params);
1129+
const result = await invocation.execute(new AbortController().signal);
1130+
1131+
expect(result.llmContent).toMatch(/File already exists, cannot create/);
1132+
expect(result.llmContent).not.toContain(existingContent); // Should not include file content on error
1133+
expect(fs.readFileSync(filePath, 'utf8')).toBe(existingContent); // File should be unchanged
1134+
});
1135+
});
1136+
});

packages/core/src/tools/edit.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,13 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
384384
);
385385
}
386386

387+
let llmContent = llmSuccessMessageParts.join(' ');
388+
if (this.config.getReadAfterEdit()) {
389+
llmContent += `\n${editData.newContent}`;
390+
}
391+
387392
return {
388-
llmContent: llmSuccessMessageParts.join(' '),
393+
llmContent,
389394
returnDisplay: displayResult,
390395
};
391396
} catch (error) {

0 commit comments

Comments
 (0)