Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { URI } from '../../../../base/common/uri.js';
8
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
9
import { FileEditKind, type ISessionFileDiff } from '../../common/state/sessionState.js';
10
import { encodeString, TestDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js';
11
import { computeSessionDiffs } from '../../node/sessionDiffAggregator.js';
12
import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js';
13
14
const TEST_SESSION_URI = 'session://test-session';
15
16
const createTestDiffService = () => new TestDiffComputeService();
17
18
function fileDiff(path: string, added: number, removed: number): ISessionFileDiff {
19
const uri = URI.file(path).toString();
20
return { after: { uri, content: { uri } }, diff: { added, removed } };
21
}
22
23
function getDiffUri(diff: ISessionFileDiff): string | undefined {
24
return diff.after?.uri ?? diff.before?.uri;
25
}
26
27
interface ISimpleDiff {
28
uri: string | undefined;
29
added: number;
30
removed: number;
31
}
32
33
function simplify(diff: ISessionFileDiff): ISimpleDiff {
34
return {
35
uri: getDiffUri(diff),
36
added: diff.diff?.added ?? 0,
37
removed: diff.diff?.removed ?? 0,
38
};
39
}
40
41
function simpleDiff(path: string, added: number, removed: number): ISimpleDiff {
42
return { uri: URI.file(path).toString(), added, removed };
43
}
44
45
suite('computeSessionDiffs', () => {
46
47
ensureNoDisposablesAreLeakedInTestSuite();
48
49
// ---- Full-mode tests (no incremental options) ---------------------------
50
51
test('returns empty array for no edits', async () => {
52
const db = new TestSessionDatabase();
53
const diffService = createTestDiffService();
54
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
55
assert.deepStrictEqual(result, []);
56
});
57
58
test('computes diffs for a single edited file', async () => {
59
const db = new TestSessionDatabase();
60
db.addEdit({
61
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
62
addedLines: undefined, removedLines: undefined,
63
beforeContent: encodeString('line1\nline2'), afterContent: encodeString('line1\nline2\nline3'),
64
});
65
66
const diffService = createTestDiffService();
67
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
68
69
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);
70
assert.strictEqual(diffService.callCount, 1);
71
});
72
73
test('populates before/after with session-db content URIs for edits', async () => {
74
const db = new TestSessionDatabase();
75
db.addEdit({
76
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
77
addedLines: undefined, removedLines: undefined,
78
beforeContent: encodeString('v1'), afterContent: encodeString('v2'),
79
});
80
db.addEdit({
81
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
82
addedLines: undefined, removedLines: undefined,
83
beforeContent: encodeString('v2'), afterContent: encodeString('v3'),
84
});
85
86
const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService());
87
88
assert.strictEqual(result.length, 1);
89
const [diff] = result;
90
const fileUri = URI.file('/a.txt').toString();
91
assert.strictEqual(diff.before?.uri, fileUri);
92
assert.strictEqual(diff.after?.uri, fileUri);
93
94
// before content points to the FIRST snapshot (tc1)
95
const beforeFields = parseSessionDbUri(diff.before!.content.uri);
96
assert.deepStrictEqual(beforeFields, {
97
sessionUri: TEST_SESSION_URI,
98
toolCallId: 'tc1',
99
filePath: '/a.txt',
100
part: 'before',
101
});
102
103
// after content points to the LAST snapshot (tc2)
104
const afterFields = parseSessionDbUri(diff.after!.content.uri);
105
assert.deepStrictEqual(afterFields, {
106
sessionUri: TEST_SESSION_URI,
107
toolCallId: 'tc2',
108
filePath: '/a.txt',
109
part: 'after',
110
});
111
});
112
113
test('omits before for creates and after for deletes', async () => {
114
const db = new TestSessionDatabase();
115
db.addEdit({
116
turnId: 't1', toolCallId: 'tc1', filePath: '/created.txt', kind: FileEditKind.Create,
117
addedLines: undefined, removedLines: undefined,
118
afterContent: encodeString('new'),
119
});
120
db.addEdit({
121
turnId: 't1', toolCallId: 'tc2', filePath: '/deleted.txt', kind: FileEditKind.Delete,
122
addedLines: undefined, removedLines: undefined,
123
beforeContent: encodeString('bye'),
124
});
125
126
const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService());
127
result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));
128
129
assert.strictEqual(result.length, 2);
130
const [created, deleted] = result;
131
assert.strictEqual(created.before, undefined, 'create has no before');
132
assert.ok(created.after, 'create has after');
133
assert.ok(deleted.before, 'delete has before');
134
assert.strictEqual(deleted.after, undefined, 'delete has no after');
135
});
136
137
test('skips files with no net change', async () => {
138
const db = new TestSessionDatabase();
139
db.addEdit({
140
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
141
addedLines: undefined, removedLines: undefined,
142
beforeContent: encodeString('same'), afterContent: encodeString('different'),
143
});
144
db.addEdit({
145
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
146
addedLines: undefined, removedLines: undefined,
147
beforeContent: encodeString('different'), afterContent: encodeString('same'),
148
});
149
150
const diffService = createTestDiffService();
151
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
152
153
// Before = tc1.before = 'same', After = tc2.after = 'same' → zero net change
154
assert.deepStrictEqual(result, []);
155
assert.strictEqual(diffService.callCount, 0, 'no diff computation needed for zero net change');
156
});
157
158
test('tracks rename chains correctly', async () => {
159
const db = new TestSessionDatabase();
160
db.addEdit({
161
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Create,
162
addedLines: undefined, removedLines: undefined,
163
afterContent: encodeString('hello'),
164
});
165
db.addEdit({
166
turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Rename, originalPath: '/a.txt',
167
addedLines: undefined, removedLines: undefined,
168
beforeContent: encodeString('hello'), afterContent: encodeString('hello world'),
169
});
170
171
const diffService = createTestDiffService();
172
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
173
174
assert.strictEqual(result.length, 1);
175
assert.strictEqual(getDiffUri(result[0]), URI.file('/b.txt').toString(), 'uses terminal path after rename');
176
});
177
178
// ---- Incremental-mode tests ---------------------------------------------
179
180
test('incremental: reuses previousDiffs for untouched files', async () => {
181
const db = new TestSessionDatabase();
182
// File A edited in turn 1 only
183
db.addEdit({
184
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
185
addedLines: undefined, removedLines: undefined,
186
beforeContent: encodeString('a-before'), afterContent: encodeString('a-after'),
187
});
188
// File B edited in turn 2
189
db.addEdit({
190
turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Edit,
191
addedLines: undefined, removedLines: undefined,
192
beforeContent: encodeString('b-before'), afterContent: encodeString('b-after\nnew'),
193
});
194
195
const previousDiffs: ISessionFileDiff[] = [
196
fileDiff('/a.txt', 42, 7),
197
];
198
199
const diffService = createTestDiffService();
200
const result = await computeSessionDiffs(
201
TEST_SESSION_URI,
202
db,
203
diffService,
204
{ changedTurnId: 't2', previousDiffs },
205
);
206
207
// Sort to ensure stable comparison
208
result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));
209
210
assert.deepStrictEqual(result.map(simplify), [
211
simpleDiff('/a.txt', 42, 7), // carried over
212
simpleDiff('/b.txt', 1, 0), // recomputed
213
]);
214
// Only file B should have triggered a diff computation
215
assert.strictEqual(diffService.callCount, 1, 'only touched file should be diffed');
216
});
217
218
test('incremental: recomputes file edited in current turn', async () => {
219
const db = new TestSessionDatabase();
220
// File A edited in turn 1 and turn 2
221
db.addEdit({
222
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
223
addedLines: undefined, removedLines: undefined,
224
beforeContent: encodeString('original'), afterContent: encodeString('after-turn1'),
225
});
226
db.addEdit({
227
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
228
addedLines: undefined, removedLines: undefined,
229
beforeContent: encodeString('after-turn1'), afterContent: encodeString('after-turn2\nextra'),
230
});
231
232
const previousDiffs: ISessionFileDiff[] = [
233
fileDiff('/a.txt', 100, 100), // stale
234
];
235
236
const diffService = createTestDiffService();
237
const result = await computeSessionDiffs(
238
TEST_SESSION_URI,
239
db,
240
diffService,
241
{ changedTurnId: 't2', previousDiffs },
242
);
243
244
// Should compare tc1.before='original' vs tc2.after='after-turn2\nextra'
245
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);
246
assert.strictEqual(diffService.callCount, 1);
247
});
248
249
test('incremental: rename in current turn drops old URI from previousDiffs', async () => {
250
const db = new TestSessionDatabase();
251
// File created in turn 1
252
db.addEdit({
253
turnId: 't1', toolCallId: 'tc1', filePath: '/old.txt', kind: FileEditKind.Create,
254
addedLines: undefined, removedLines: undefined,
255
afterContent: encodeString('content'),
256
});
257
// Renamed in turn 2
258
db.addEdit({
259
turnId: 't2', toolCallId: 'tc2', filePath: '/new.txt', kind: FileEditKind.Rename,
260
originalPath: '/old.txt',
261
addedLines: undefined, removedLines: undefined,
262
beforeContent: encodeString('content'), afterContent: encodeString('content'),
263
});
264
265
const previousDiffs: ISessionFileDiff[] = [
266
fileDiff('/old.txt', 5, 0),
267
];
268
269
const diffService = createTestDiffService();
270
const result = await computeSessionDiffs(
271
TEST_SESSION_URI,
272
db,
273
diffService,
274
{ changedTurnId: 't2', previousDiffs },
275
);
276
277
// Create → Rename with same content: before='' (create), after='content' (rename)
278
assert.strictEqual(result.length, 1);
279
assert.strictEqual(getDiffUri(result[0]), URI.file('/new.txt').toString(), 'uses new URI after rename');
280
});
281
282
test('incremental: file with zero net change in current turn is excluded even if in previousDiffs', async () => {
283
const db = new TestSessionDatabase();
284
db.addEdit({
285
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
286
addedLines: undefined, removedLines: undefined,
287
beforeContent: encodeString('original'), afterContent: encodeString('modified'),
288
});
289
// Turn 2 reverts the change
290
db.addEdit({
291
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
292
addedLines: undefined, removedLines: undefined,
293
beforeContent: encodeString('modified'), afterContent: encodeString('original'),
294
});
295
296
const previousDiffs: ISessionFileDiff[] = [
297
fileDiff('/a.txt', 10, 5),
298
];
299
300
const diffService = createTestDiffService();
301
const result = await computeSessionDiffs(
302
TEST_SESSION_URI,
303
db,
304
diffService,
305
{ changedTurnId: 't2', previousDiffs },
306
);
307
308
// Net change is zero (reverted), so file should be excluded
309
assert.deepStrictEqual(result, []);
310
});
311
312
test('incremental: previousDiffs entry for file not in current identities is dropped (slow path)', async () => {
313
const db = new TestSessionDatabase();
314
// File A was edited in turn 1 and is in previousDiffs
315
db.addEdit({
316
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
317
addedLines: undefined, removedLines: undefined,
318
beforeContent: encodeString('before'), afterContent: encodeString('after'),
319
});
320
// File A is edited again in turn 2 → triggers slow path (re-edit of existing file)
321
db.addEdit({
322
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
323
addedLines: undefined, removedLines: undefined,
324
beforeContent: encodeString('after'), afterContent: encodeString('latest\nline'),
325
});
326
327
const previousDiffs: ISessionFileDiff[] = [
328
fileDiff('/a.txt', 1, 0),
329
fileDiff('/orphan.txt', 99, 99), // no longer in DB
330
];
331
332
const diffService = createTestDiffService();
333
const result = await computeSessionDiffs(
334
TEST_SESSION_URI,
335
db,
336
diffService,
337
{ changedTurnId: 't2', previousDiffs },
338
);
339
340
// Slow path: orphan is dropped because it has no identity in the full graph
341
assert.strictEqual(result.length, 1);
342
assert.strictEqual(getDiffUri(result[0]), URI.file('/a.txt').toString());
343
});
344
345
test('full mode recomputes all files (no incremental options)', async () => {
346
const db = new TestSessionDatabase();
347
db.addEdit({
348
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
349
addedLines: undefined, removedLines: undefined,
350
beforeContent: encodeString('a'), afterContent: encodeString('a\nb'),
351
});
352
db.addEdit({
353
turnId: 't1', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Create,
354
addedLines: undefined, removedLines: undefined,
355
afterContent: encodeString('new'),
356
});
357
358
const diffService = createTestDiffService();
359
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
360
361
assert.strictEqual(result.length, 2);
362
assert.strictEqual(diffService.callCount, 2, 'both files should be diffed in full mode');
363
});
364
365
// ---- Fast-path tests (turn-scoped query optimization) -------------------
366
367
test('incremental fast path: new files only uses getFileEditsByTurn, not getAllFileEdits', async () => {
368
const db = new TestSessionDatabase();
369
// Turn 1: existing file untouched in turn 2
370
db.addEdit({
371
turnId: 't1', toolCallId: 'tc1', filePath: '/old.txt', kind: FileEditKind.Edit,
372
addedLines: undefined, removedLines: undefined,
373
beforeContent: encodeString('old-before'), afterContent: encodeString('old-after'),
374
});
375
// Turn 2: creates a new file
376
db.addEdit({
377
turnId: 't2', toolCallId: 'tc2', filePath: '/new.txt', kind: FileEditKind.Create,
378
addedLines: undefined, removedLines: undefined,
379
afterContent: encodeString('brand new'),
380
});
381
382
const previousDiffs: ISessionFileDiff[] = [
383
fileDiff('/old.txt', 3, 1),
384
];
385
386
const diffService = createTestDiffService();
387
const result = await computeSessionDiffs(
388
TEST_SESSION_URI,
389
db,
390
diffService,
391
{ changedTurnId: 't2', previousDiffs },
392
);
393
394
// Fast path: only getFileEditsByTurn called, not getAllFileEdits
395
assert.strictEqual(db.getFileEditsByTurnCalls, 1);
396
assert.strictEqual(db.getAllFileEditsCalls, 0, 'fast path should not call getAllFileEdits');
397
398
result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));
399
assert.deepStrictEqual(result.map(simplify), [
400
simpleDiff('/new.txt', 1, 0),
401
simpleDiff('/old.txt', 3, 1), // carried over
402
]);
403
});
404
405
test('incremental slow path: re-edit of existing file falls back to getAllFileEdits', async () => {
406
const db = new TestSessionDatabase();
407
// Turn 1: edit file A
408
db.addEdit({
409
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
410
addedLines: undefined, removedLines: undefined,
411
beforeContent: encodeString('original'), afterContent: encodeString('turn1'),
412
});
413
// Turn 2: edit file A again
414
db.addEdit({
415
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
416
addedLines: undefined, removedLines: undefined,
417
beforeContent: encodeString('turn1'), afterContent: encodeString('turn2\nextra'),
418
});
419
420
const previousDiffs: ISessionFileDiff[] = [
421
fileDiff('/a.txt', 5, 0),
422
];
423
424
const diffService = createTestDiffService();
425
const result = await computeSessionDiffs(
426
TEST_SESSION_URI,
427
db,
428
diffService,
429
{ changedTurnId: 't2', previousDiffs },
430
);
431
432
// Slow path: falls back to getAllFileEdits because /a.txt is in previousDiffs
433
assert.strictEqual(db.getFileEditsByTurnCalls, 1, 'should try turn-scoped query first');
434
assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back to getAllFileEdits');
435
436
// Cumulative diff: original → turn2\nextra
437
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);
438
});
439
440
test('incremental slow path: rename in current turn falls back to getAllFileEdits', async () => {
441
const db = new TestSessionDatabase();
442
db.addEdit({
443
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Create,
444
addedLines: undefined, removedLines: undefined,
445
afterContent: encodeString('content'),
446
});
447
db.addEdit({
448
turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Rename,
449
originalPath: '/a.txt',
450
addedLines: undefined, removedLines: undefined,
451
beforeContent: encodeString('content'), afterContent: encodeString('content'),
452
});
453
454
const previousDiffs: ISessionFileDiff[] = [
455
fileDiff('/a.txt', 1, 0),
456
];
457
458
const diffService = createTestDiffService();
459
await computeSessionDiffs(
460
TEST_SESSION_URI,
461
db,
462
diffService,
463
{ changedTurnId: 't2', previousDiffs },
464
);
465
466
assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back for renames');
467
});
468
469
test('incremental: no edits in turn returns previousDiffs unchanged', async () => {
470
const db = new TestSessionDatabase();
471
db.addEdit({
472
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
473
addedLines: undefined, removedLines: undefined,
474
beforeContent: encodeString('before'), afterContent: encodeString('after'),
475
});
476
477
const previousDiffs: ISessionFileDiff[] = [
478
fileDiff('/a.txt', 5, 2),
479
];
480
481
const diffService = createTestDiffService();
482
const result = await computeSessionDiffs(
483
TEST_SESSION_URI,
484
db,
485
diffService,
486
{ changedTurnId: 't2', previousDiffs },
487
);
488
489
assert.strictEqual(db.getAllFileEditsCalls, 0, 'no computation needed');
490
assert.deepStrictEqual(result, previousDiffs);
491
});
492
});
493
494