Skip to content

Commit 286c5d6

Browse files
Lightning00BladeDevtools-frontend LUCI CQ
authored andcommitted
[AI Assistance] Use unified diff to patching
Bug: 399561340 Change-Id: I895a443f08e2c3e5eeccdae55c3cd3b4d208558b Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6434494 Reviewed-by: Alex Rudenko <[email protected]> Commit-Queue: Nikolay Vitkov <[email protected]>
1 parent 70fb05e commit 286c5d6

File tree

5 files changed

+332
-29
lines changed

5 files changed

+332
-29
lines changed

front_end/core/host/AidaClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export enum CitationSourceType {
228228
TRAINING_DATA = 'TRAINING_DATA',
229229
WORLD_FACTS = 'WORLD_FACTS',
230230
LOCAL_FACTS = 'LOCAL_FACTS',
231-
INDIRECT = 'INDERECT',
231+
INDIRECT = 'INDIRECT',
232232
}
233233

234234
export interface Citation {

front_end/models/ai_assistance/AgentProject.test.ts

Lines changed: 192 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,198 @@ describeWithEnvironment('AgentProject', () => {
6565
assert.deepEqual(project.getProcessedFiles(), ['index.html']);
6666
});
6767

68-
it('can write files files', async () => {
69-
const {project} = await mockProject();
70-
project.writeFile('index.html', 'updated');
71-
assert.deepEqual(project.readFile('index.html'), 'updated');
68+
describe('write file', () => {
69+
describe('full', () => {
70+
it('can write files files', async () => {
71+
const {project} = await mockProject();
72+
project.writeFile('index.html', 'updated');
73+
assert.deepEqual(project.readFile('index.html'), 'updated');
74+
});
75+
});
76+
77+
describe('unified', () => {
78+
it('can write files', async () => {
79+
const {project} = await mockProject();
80+
const unifiedDiff = `\`\`\`\`\`
81+
diff
82+
--- a/index.html
83+
+++ b/index.html
84+
@@ -817,5 +817,5 @@
85+
-content
86+
+updated
87+
\`\`\`\`\``;
88+
89+
project.writeFile('index.html', unifiedDiff, AiAssistanceModel.ReplaceStrategy.UNIFIED_DIFF);
90+
assert.deepEqual(project.readFile('index.html'), 'updated');
91+
});
92+
93+
it('can write files with multiple changes', async () => {
94+
const {project} = await mockProject(
95+
[
96+
{
97+
path: 'index.css',
98+
content: `Line:1
99+
Line:2
100+
Line:3
101+
Line:4
102+
Line:5`,
103+
},
104+
],
105+
);
106+
const unifiedDiff = `\`\`\`\`\`
107+
diff
108+
--- a/index.css
109+
+++ b/index.css
110+
@@ -817,1 +817,1 @@
111+
-Line:1
112+
+LineUpdated:1
113+
@@ -856,7 +857,7 @@
114+
-Line:4
115+
+LineUpdated:4
116+
\`\`\`\`\``;
117+
118+
project.writeFile('index.css', unifiedDiff, AiAssistanceModel.ReplaceStrategy.UNIFIED_DIFF);
119+
assert.deepEqual(project.readFile('index.css'), `LineUpdated:1
120+
Line:2
121+
Line:3
122+
LineUpdated:4
123+
Line:5`);
124+
});
125+
126+
it('can write files with only addition', async () => {
127+
const {project} = await mockProject(
128+
[
129+
{
130+
path: 'index.css',
131+
content: '',
132+
},
133+
],
134+
);
135+
const unifiedDiff = `\`\`\`\`\`
136+
diff
137+
--- a/index.css
138+
+++ b/index.css
139+
@@ -817,1 +817,1 @@
140+
+Line:1
141+
+Line:4
142+
\`\`\`\`\``;
143+
144+
project.writeFile('index.css', unifiedDiff, AiAssistanceModel.ReplaceStrategy.UNIFIED_DIFF);
145+
assert.deepEqual(project.readFile('index.css'), `Line:1
146+
Line:4`);
147+
});
148+
149+
it('can write files with multiple additions', async () => {
150+
const {project} = await mockProject(
151+
[
152+
{
153+
path: 'index.css',
154+
content: `Line:1
155+
Line:2
156+
Line:3
157+
Line:4
158+
Line:5`,
159+
},
160+
],
161+
);
162+
const unifiedDiff = `\`\`\`\`\`
163+
diff
164+
--- a/index.css
165+
+++ b/index.css
166+
@@ -817,1 +817,1 @@
167+
-Line:1
168+
+LineUpdated:1
169+
+LineUpdated:1.5
170+
\`\`\`\`\``;
171+
172+
project.writeFile('index.css', unifiedDiff, AiAssistanceModel.ReplaceStrategy.UNIFIED_DIFF);
173+
assert.deepEqual(project.readFile('index.css'), `LineUpdated:1
174+
LineUpdated:1.5
175+
Line:2
176+
Line:3
177+
Line:4
178+
Line:5`);
179+
});
180+
181+
it('can write files with only deletion', async () => {
182+
const {project} = await mockProject(
183+
[
184+
{
185+
path: 'index.css',
186+
content: `Line:1
187+
Line:2
188+
Line:3
189+
Line:4
190+
Line:5`,
191+
},
192+
],
193+
);
194+
const unifiedDiff = `\`\`\`\`\`
195+
diff
196+
--- a/index.css
197+
+++ b/index.css
198+
@@ -817,1 +817,1 @@
199+
Line:1
200+
-Line:2
201+
Line:3
202+
\`\`\`\`\``;
203+
204+
project.writeFile('index.css', unifiedDiff, AiAssistanceModel.ReplaceStrategy.UNIFIED_DIFF);
205+
assert.deepEqual(project.readFile('index.css'), `Line:1
206+
Line:3
207+
Line:4
208+
Line:5`);
209+
});
210+
211+
it('can write files with only deletion no search lines', async () => {
212+
const {project} = await mockProject(
213+
[
214+
{
215+
path: 'index.css',
216+
content: 'Line:1',
217+
},
218+
],
219+
);
220+
const unifiedDiff = `\`\`\`\`\`
221+
diff
222+
--- a/index.css
223+
+++ b/index.css
224+
@@ -817,1 +817,1 @@
225+
-Line:1
226+
\`\`\`\`\``;
227+
228+
project.writeFile('index.css', unifiedDiff, AiAssistanceModel.ReplaceStrategy.UNIFIED_DIFF);
229+
assert.deepEqual(project.readFile('index.css'), '');
230+
});
231+
232+
it('can write files with first line next to @@', async () => {
233+
const {project} = await mockProject(
234+
[
235+
{
236+
path: 'index.css',
237+
content: `Line:1
238+
Line:2
239+
Line:3
240+
Line:4
241+
Line:5`,
242+
},
243+
],
244+
);
245+
const unifiedDiff = `\`\`\`\`\`
246+
diff
247+
--- a/index.css
248+
+++ b/index.css
249+
@@ -817,1 +817,1 @@-Line:1
250+
-Line:2
251+
Line:3
252+
\`\`\`\`\``;
253+
254+
project.writeFile('index.css', unifiedDiff, AiAssistanceModel.ReplaceStrategy.UNIFIED_DIFF);
255+
assert.deepEqual(project.readFile('index.css'), `Line:3
256+
Line:4
257+
Line:5`);
258+
});
259+
});
72260
});
73261

74262
describe('limits', () => {

front_end/models/ai_assistance/AgentProject.ts

Lines changed: 105 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import type * as Workspace from '../workspace/workspace.js';
99

1010
import {debugLog} from './debug.js';
1111

12+
const LINE_END_RE = /\r\n?|\n/;
13+
14+
export const enum ReplaceStrategy {
15+
FULL_FILE = 'full',
16+
UNIFIED_DIFF = 'unified'
17+
}
18+
1219
/**
1320
* AgentProject wraps around a Workspace.Workspace.Project and
1421
* implements AI Assistance-specific logic for accessing workspace files
@@ -18,7 +25,7 @@ export class AgentProject {
1825
#project: Workspace.Workspace.Project;
1926
#ignoredFolderNames = new Set(['node_modules']);
2027
#filesChanged = new Set<string>();
21-
#linesChanged = 0;
28+
#totalLinesChanged = 0;
2229

2330
readonly #maxFilesChanged: number;
2431
readonly #maxLinesChanged: number;
@@ -75,27 +82,26 @@ export class AgentProject {
7582
* This method updates the file content in the working copy of the
7683
* UiSourceCode identified by the filepath.
7784
*/
78-
writeFile(filepath: string, content: string): void {
85+
writeFile(filepath: string, update: string, mode = ReplaceStrategy.FULL_FILE): void {
7986
const {map} = this.#indexFiles();
8087
const uiSourceCode = map.get(filepath);
8188
if (!uiSourceCode) {
8289
throw new Error(`UISourceCode ${filepath} not found`);
8390
}
8491
const currentContent = this.readFile(filepath);
85-
const lineEndRe = /\r\n?|\n/;
86-
let linesChanged = 0;
87-
if (currentContent) {
88-
const diff = Diff.Diff.DiffWrapper.lineDiff(currentContent.split(lineEndRe), content.split(lineEndRe));
89-
for (const item of diff) {
90-
if (item[0] !== Diff.Diff.Operation.Equal) {
91-
linesChanged++;
92-
}
93-
}
94-
} else {
95-
linesChanged += content.split(lineEndRe).length;
92+
let content: string;
93+
switch (mode) {
94+
case ReplaceStrategy.FULL_FILE:
95+
content = update;
96+
break;
97+
case ReplaceStrategy.UNIFIED_DIFF:
98+
content = this.#writeWithUnifiedDiff(update, currentContent);
99+
break;
96100
}
97101

98-
if (this.#linesChanged + linesChanged > this.#maxLinesChanged) {
102+
const linesChanged = this.getLinesChanged(currentContent, content);
103+
104+
if (this.#totalLinesChanged + linesChanged > this.#maxLinesChanged) {
99105
throw new Error('Too many lines changed');
100106
}
101107

@@ -104,11 +110,92 @@ export class AgentProject {
104110
this.#filesChanged.delete(filepath);
105111
throw new Error('Too many files changed');
106112
}
107-
this.#linesChanged += linesChanged;
113+
this.#totalLinesChanged += linesChanged;
108114
uiSourceCode.setWorkingCopy(content);
109115
uiSourceCode.setContainsAiChanges(true);
110116
}
111117

118+
#writeWithUnifiedDiff(llmDiff: string, content = ''): string {
119+
let updatedContent = content;
120+
const diffChunk = llmDiff.trim();
121+
const normalizedDiffLines = diffChunk.split(LINE_END_RE);
122+
123+
const lineAfterSeparatorRegEx = /^@@.*@@([- +].*)/;
124+
const changeChunk: string[][] = [];
125+
let currentChunk: string[] = [];
126+
for (const line of normalizedDiffLines) {
127+
if (line.startsWith('```')) {
128+
continue;
129+
}
130+
131+
// The ending is not always @@
132+
if (line.startsWith('@@')) {
133+
line.search('@@');
134+
currentChunk = [];
135+
changeChunk.push(currentChunk);
136+
if (!line.endsWith('@@')) {
137+
const match = line.match(lineAfterSeparatorRegEx);
138+
if (match?.[1]) {
139+
currentChunk.push(match[1]);
140+
}
141+
}
142+
} else {
143+
currentChunk.push(line);
144+
}
145+
}
146+
147+
for (const chunk of changeChunk) {
148+
const search = [];
149+
const replace = [];
150+
for (const changeLine of chunk) {
151+
// Unified diff first char is ' ', '-', '+'
152+
// to represent what happened to the line
153+
const line = changeLine.slice(1);
154+
155+
if (changeLine.startsWith('-')) {
156+
search.push(line);
157+
} else if (changeLine.startsWith('+')) {
158+
replace.push(line);
159+
} else {
160+
search.push(line);
161+
replace.push(line);
162+
}
163+
}
164+
if (replace.length === 0) {
165+
const searchString = search.join('\n');
166+
// If we remove we want to
167+
if (updatedContent.search(searchString + '\n') !== -1) {
168+
updatedContent = updatedContent.replace(searchString + '\n', '');
169+
} else {
170+
updatedContent = updatedContent.replace(searchString, '');
171+
}
172+
} else if (search.length === 0) {
173+
// This just adds it to the beginning of the file
174+
updatedContent = updatedContent.replace('', replace.join('\n'));
175+
} else {
176+
updatedContent = updatedContent.replace(search.join('\n'), replace.join('\n'));
177+
}
178+
}
179+
180+
return updatedContent;
181+
}
182+
183+
getLinesChanged(currentContent: string|undefined, updatedContent: string): number {
184+
let linesChanged = 0;
185+
if (currentContent) {
186+
const diff = Diff.Diff.DiffWrapper.lineDiff(updatedContent.split(LINE_END_RE), currentContent.split(LINE_END_RE));
187+
for (const item of diff) {
188+
if (item[0] !== Diff.Diff.Operation.Equal) {
189+
linesChanged++;
190+
}
191+
}
192+
} else {
193+
linesChanged += updatedContent.split(LINE_END_RE).length;
194+
}
195+
196+
return linesChanged;
197+
}
198+
112199
/**
113200
* This method searches in files for the agent and provides the
114201
* matches to the agent.
@@ -159,11 +246,11 @@ export class AgentProject {
159246
const map = new Map();
160247
// TODO: this could be optimized and cached.
161248
for (const uiSourceCode of this.#project.uiSourceCodes()) {
162-
const pathParths = Persistence.FileSystemWorkspaceBinding.FileSystemWorkspaceBinding.relativePath(uiSourceCode);
163-
if (this.#shouldSkipPath(pathParths)) {
249+
const pathParts = Persistence.FileSystemWorkspaceBinding.FileSystemWorkspaceBinding.relativePath(uiSourceCode);
250+
if (this.#shouldSkipPath(pathParts)) {
164251
continue;
165252
}
166-
const path = pathParths.join('/');
253+
const path = pathParts.join('/');
167254
files.push(path);
168255
map.set(path, uiSourceCode);
169256
}

front_end/models/ai_assistance/agents/AiAgent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,12 @@ export abstract class AiAgent<T> {
313313
}
314314
const enableAidaFunctionCalling = declarations.length && !this.functionCallEmulationEnabled;
315315
const userTier = Host.AidaClient.convertToUserTierEnum(this.userTier);
316-
const premable = userTier === Host.AidaClient.UserTier.TESTERS ? this.preamble : undefined;
316+
const preamble = userTier === Host.AidaClient.UserTier.TESTERS ? this.preamble : undefined;
317317
const facts = Array.from(this.#facts);
318318
const request: Host.AidaClient.AidaRequest = {
319319
client: Host.AidaClient.CLIENT_NAME,
320320
current_message: currentMessage,
321-
preamble: premable,
321+
preamble,
322322

323323
historical_contexts: history.length ? history : undefined,
324324
facts: facts.length ? facts : undefined,

0 commit comments

Comments
 (0)