src/vs/platform/agentHost/node/sessionDiffAggregator.ts

460 LOC · 375 covered · 85 uncovered · 62 ranges · 957 concepts · 26 introducers · 502 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- sessionDiffAggregator.ts ×5
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 { URI } from '../../../base/common/uri.js';
7 > import type { IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js';
8 > import type { IDiffComputeService } from '../common/diffComputeService.js';
9 > import { FileEditKind, type ISessionFileDiff } from '../common/state/sessionState.js';
10 > import { buildSessionDbUri } from './shared/fileEditTracker.js';
11 >
12 > function getFileEditUri(diff: ISessionFileDiff): string | undefined { sessionDiffAggregator.ts ×17
13 > return diff.after?.uri ?? diff.before?.uri;
14 > }
16 > function createSessionFileDiff(beforeSessionUri: string, afterSessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { sessionDiffAggregator.ts ×2
17 > const hasBefore = identity.firstKind !== FileEditKind.Create;
18 > const hasAfter = identity.lastKind !== FileEditKind.Delete;
19 > return {
20 > ...(hasBefore ? {
22 > uri: URI.file(identity.firstFilePath).toString(),
23 > content: { uri: buildSessionDbUri(beforeSessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') },
24 > },
26 > ...(hasAfter ? {
27 > after: {
28 > uri: URI.file(identity.terminalPath).toString(),
29 > content: { uri: buildSessionDbUri(afterSessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') },
30 > },
31 > } : {}),
32 > diff: { added, removed },
33 > };
34 > }
36 > /**
37 > * Represents a file's identity across renames, tracking its first and last
38 > * snapshots in the session for diff computation.
39 > */
40 > interface IFileIdentity {
41 > /** The last known URI for this file. */
42 > terminalPath: string;
43 > /** Tool call ID of the first edit (for fetching "before" content). */
44 > firstToolCallId: string;
45 > /** File path used in the first edit's database record. */
46 > firstFilePath: string;
47 > /** The kind of the first edit (Create means no "before" content). */
48 > firstKind: FileEditKind;
49 > /** Index into the sources array of the DB that owns the first edit. */
50 > firstSourceIdx: number;
51 > /** Tool call ID of the last edit (for fetching "after" content). */
52 > lastToolCallId: string;
53 > /** File path used in the last edit's database record. */
54 > lastFilePath: string;
55 > /** The kind of the last edit (Delete means no "after" content). */
56 > lastKind: FileEditKind;
57 > /** Index into the sources array of the DB that owns the last edit. */
58 > lastSourceIdx: number;
59 > }
60 >
61 > /**
62 > * A single database whose file edits contribute to a session's aggregated
63 > * diff. For single-chat sessions there is one source (the session DB); for
64 > * multi-chat sessions each peer chat records edits into its own DB, so the
65 > * session changeset unions the session DB with every peer chat DB.
66 > */
67 > export interface ISessionDiffSource {
68 > /**
69 > * The session / peer-chat URI that owns {@link db}. Encoded into the
70 > * `session-db:` content URIs so the resource resolver opens the correct
71 > * database when fetching before/after blobs.
72 > */
73 > sessionUri: string;
74 > /** The database holding this source's file edits. */
75 > db: ISessionDatabase;
76 > }
77 >
78 > /**
79 > * Options for incremental diff computation. When provided,
80 > * {@link computeSessionDiffs} reuses previous diff results for file
81 > * identities that were not touched in the given turn.
82 > */
83 > export interface IIncrementalDiffOptions {
84 > /** The turn ID that just completed — only identities touched by edits
85 > * in this turn will be recomputed. */
86 > changedTurnId: string;
87 > /** Previously computed diffs (from the last dispatch). Entries for
88 > * untouched identities are carried over without recomputation. */
89 > previousDiffs: ISessionFileDiff[];
90 > }
91 >
92 > /**
93 > * Computes aggregated diff statistics for a session by comparing each file's
94 > * first snapshot to its last snapshot, tracking renames across the chain.
95 > *
96 > * When {@link incremental} is provided, only identities that were touched
97 > * by edits in the given turn are recomputed; all other identities reuse
98 > * the previous diff results. This avoids expensive content fetches and
99 > * diff computations for unchanged files.
100 > *
101 > * Returns an {@link ISessionFileDiff} array with the "last known URI" for each
102 > * file and the total lines added/removed across the session.
103 > */
104 > export async function computeSessionDiffs( sessionDiffAggregator.ts ×3
105 > sessionUri: string,
106 > db: ISessionDatabase,
107 > diffService: IDiffComputeService,
108 > incremental?: IIncrementalDiffOptions,
109 > ): Promise<ISessionFileDiff[]> {
110 > // Full mode (no incremental) is the single-source case of the unioned
111 > // computation — delegate so the identity-graph + diff logic lives in one
112 > // place and multi-chat sessions reuse the exact same code path.
113 > if (!incremental) {
114 > return computeUnionedDiffs([{ sessionUri, db }], diffService); sessionDiffAggregator.ts ×1
115 > }
117 > // Incremental mode (single source): try to fetch only the current turn's
118 > // edits. When the turn only introduces new files (no renames, no re-edits
119 > // of previously changed files), the full edit history is not needed.
120 > let edits: IFileEditRecord[];
121 > let fastPath = false;
122 >
123 > const turnEdits = await db.getFileEditsByTurn(incremental.changedTurnId);
124 > if (turnEdits.length === 0) {
125 > return [...incremental.previousDiffs]; sessionDiffAggregator.ts ×1
126 > }
128 > const previousDiffsUris = new Set(incremental.previousDiffs.map(getFileEditUri));
129 > const needsFullHistory = turnEdits.some(e =>
130 > e.kind === FileEditKind.Rename ||
131 > previousDiffsUris.has(URI.file(e.filePath).toString()) sessionDiffAggregator.ts ×1
133 >
134 > if (needsFullHistory) {
135 > edits = await db.getAllFileEdits(); sessionDiffAggregator.ts ×4
137 > edits = turnEdits; sessionDiffAggregator.ts ×3
138 > fastPath = true;
139 > }
141 > if (edits.length === 0) {
142 return [];
143 }
145 > // Build file identity graph. We need to:
146 > // 1. Track renames: when a file is renamed A→B, its identity follows to B
147 > // 2. Find the first "before" snapshot and last "after" snapshot per identity
148 >
149 > // Maps a file path to its canonical identity key (follows rename chains)
150 > const pathToIdentityKey = new Map<string, string>();
151 > // Maps identity keys to their accumulated data
152 > const identities = new Map<string, IFileIdentity>();
153 > // Track which identity keys were touched by the incremental turn.
154 > // In fast-path mode all identities are from the current turn, so no tracking needed.
155 > const touchedIdentityKeys = !fastPath ? new Set<string>() : undefined; sessionDiffAggregator.ts ×3
156 >
157 > for (const edit of edits) {
158 > let identityKey: string; sessionDiffAggregator.ts ×17
159 >
160 > if (edit.kind === FileEditKind.Rename && edit.originalPath) {
161 > // Rename: follow the chain from originalPath to find the identity sessionDiffAggregator.ts ×1
162 > identityKey = pathToIdentityKey.get(edit.originalPath) ?? edit.originalPath;
163 > // Update the mapping: the new path now points to the same identity
164 > pathToIdentityKey.set(edit.filePath, identityKey);
165 > // Remove old path mapping (the file no longer exists at that path)
166 > pathToIdentityKey.delete(edit.originalPath);
168 > // Regular edit, create, or delete: look up or create identity
169 > identityKey = pathToIdentityKey.get(edit.filePath) ?? edit.filePath;
170 > pathToIdentityKey.set(edit.filePath, identityKey);
171 > }
172 >
173 > if (touchedIdentityKeys && edit.turnId === incremental.changedTurnId) {
174 > touchedIdentityKeys.add(identityKey); sessionDiffAggregator.ts ×4
175 > }
177 > const existing = identities.get(identityKey);
178 > if (!existing) {
179 > // First time seeing this file identity
180 > identities.set(identityKey, {
181 > terminalPath: edit.filePath,
182 > firstToolCallId: edit.toolCallId,
183 > firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath,
184 > firstKind: edit.kind,
185 > firstSourceIdx: 0,
186 > lastToolCallId: edit.toolCallId,
187 > lastFilePath: edit.filePath,
188 > lastKind: edit.kind,
189 > lastSourceIdx: 0,
190 > });
191 > } else {
192 > // Update last snapshot info and terminal path sessionDiffAggregator.ts ×4
193 > existing.terminalPath = edit.filePath;
194 > existing.lastToolCallId = edit.toolCallId;
195 > existing.lastFilePath = edit.filePath;
196 > existing.lastKind = edit.kind;
197 > }
199 >
200 > // In incremental slow-path mode, build a lookup map from URI string →
201 > // previous diff so untouched identities can carry over their previous results.
202 > const previousDiffsMap = !fastPath
203 > ? new Map(incremental.previousDiffs.map(d => [getFileEditUri(d), d])) sessionDiffAggregator.ts ×4
204 > : undefined; sessionDiffAggregator.ts ×3
206 > // Compute diffs for each file identity
207 > const results: ISessionFileDiff[] = [];
208 > const diffPromises: Promise<void>[] = [];
209 >
210 > for (const [identityKey, identity] of identities) {
211 > // In incremental slow-path mode, skip recomputation for untouched identities sessionDiffAggregator.ts ×17
212 > if (touchedIdentityKeys && !touchedIdentityKeys.has(identityKey)) {
213 const uri = URI.file(identity.terminalPath).toString();
214 const prev = previousDiffsMap!.get(uri);
215 if (prev) {
216 results.push(prev);
217 }
218 // If no previous entry, the file previously had zero net change — skip
219 continue;
220 }
222 > diffPromises.push((async () => {
223 > // Determine "before" text
224 > let beforeText: string;
225 > if (identity.firstKind === FileEditKind.Create) {
226 > beforeText = ''; sessionDiffAggregator.ts ×1
228 > const content = await db.readFileEditContent(identity.firstToolCallId, identity.firstFilePath); sessionDiffAggregator.ts ×1
229 > beforeText = content?.beforeContent ? new TextDecoder().decode(content.beforeContent) : '';
230 > }
232 > // Determine "after" text
233 > let afterText: string;
234 > if (identity.lastKind === FileEditKind.Delete) {
235 afterText = '';
237 > const content = await db.readFileEditContent(identity.lastToolCallId, identity.lastFilePath);
238 > afterText = content?.afterContent ? new TextDecoder().decode(content.afterContent) : '';
239 > }
240 >
241 > // Skip files with no net change
242 > if (beforeText === afterText) {
244 > }
246 > const counts = await diffService.computeDiffCounts(beforeText, afterText);
247 > results.push(createSessionFileDiff(sessionUri, sessionUri, identity, counts.added, counts.removed));
249 > }
250 >
251 > await Promise.allSettled(diffPromises);
252 >
253 > // In fast-path mode, carry over previous diffs for untouched files
254 > // (they were not in the identity graph since we only loaded the current turn)
255 > if (fastPath) {
256 > results.push(...incremental.previousDiffs); sessionDiffAggregator.ts ×3
257 > }
259 > return results;
260 > }
262 > /**
263 > * Computes aggregated diff statistics across one or more {@link ISessionDiffSource}
264 > * databases by unioning their file edits and comparing each file's first
265 > * snapshot to its last snapshot, tracking renames across the chain.
266 > *
267 > * Single-chat sessions pass one source (the session DB). Multi-chat sessions
268 > * pass the session DB plus every peer chat DB so peer-chat edits (recorded into
269 > * their own databases) roll up into the session-level changes. Each file
270 > * identity remembers which source owns its first and last snapshots so the
271 > * before/after content is read from — and its `session-db:` content URI encodes —
272 > * the correct database.
273 > *
274 > * Sources are unioned in array order (session first, peers next); within a
275 > * source, edits keep their insertion order. When a file is touched by more than
276 > * one source the "before" comes from the earliest source that touched it and the
277 > * "after" from the latest, which matches the shared working tree the chats edit.
278 > *
279 > * TODO (debt): this always does a full recompute — it ignores the
280 > * {@link IIncrementalDiffOptions} fast/slow paths that {@link computeSessionDiffs}
281 > * uses for single-source sessions. An incremental union is a safe follow-up:
282 > * the per-identity `firstSourceIdx`/`lastSourceIdx` already carry the provenance
283 > * needed to recompute only the turn's owning source plus cross-source files and
284 > * carry over the rest. Requires plumbing the owning source of `changedTurnId`
285 > * through `onTurnComplete` → `_doComputeStaticChangeset`. See tracking issue.
286 > */
287 > export async function computeUnionedDiffs( sessionDiffAggregator.ts ×2
288 > sources: readonly ISessionDiffSource[],
289 > diffService: IDiffComputeService,
290 > ): Promise<ISessionFileDiff[]> {
291 > // Load every source's edits in parallel, then concatenate in source order so
292 > // the identity graph sees a deterministic session-first ordering while each
293 > // source keeps its own insertion order.
294 > const perSourceEdits = await Promise.all(sources.map(source => source.db.getAllFileEdits()));
295 >
296 > const pathToIdentityKey = new Map<string, string>();
297 > const identities = new Map<string, IFileIdentity>();
298 > let totalEdits = 0;
299 >
300 > for (let sourceIdx = 0; sourceIdx < perSourceEdits.length; sourceIdx++) {
301 > for (const edit of perSourceEdits[sourceIdx]) {
302 > totalEdits++; sessionDiffAggregator.ts ×8
303 > let identityKey: string;
304 >
305 > if (edit.kind === FileEditKind.Rename && edit.originalPath) {
306 > identityKey = pathToIdentityKey.get(edit.originalPath) ?? edit.originalPath; sessionDiffAggregator.ts ×1
307 > pathToIdentityKey.set(edit.filePath, identityKey);
308 > pathToIdentityKey.delete(edit.originalPath);
310 > identityKey = pathToIdentityKey.get(edit.filePath) ?? edit.filePath;
311 > pathToIdentityKey.set(edit.filePath, identityKey);
312 > }
313 >
314 > const existing = identities.get(identityKey);
315 > if (!existing) {
316 > identities.set(identityKey, {
317 > terminalPath: edit.filePath,
318 > firstToolCallId: edit.toolCallId,
319 > firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath,
320 > firstKind: edit.kind,
321 > firstSourceIdx: sourceIdx,
322 > lastToolCallId: edit.toolCallId,
323 > lastFilePath: edit.filePath,
324 > lastKind: edit.kind,
325 > lastSourceIdx: sourceIdx,
326 > });
327 > } else {
328 > existing.terminalPath = edit.filePath; sessionDiffAggregator.ts ×1
329 > existing.lastToolCallId = edit.toolCallId;
330 > existing.lastFilePath = edit.filePath;
331 > existing.lastKind = edit.kind;
332 > existing.lastSourceIdx = sourceIdx;
333 > }
336 >
337 > if (totalEdits === 0) {
338 > return []; sessionDiffAggregator.ts ×1
339 > }
341 > const results: ISessionFileDiff[] = [];
342 > const diffPromises: Promise<void>[] = [];
343 >
344 > for (const identity of identities.values()) {
345 > diffPromises.push((async () => {
346 > const firstSource = sources[identity.firstSourceIdx];
347 > const lastSource = sources[identity.lastSourceIdx];
348 >
349 > let beforeText: string;
350 > if (identity.firstKind === FileEditKind.Create) {
351 > beforeText = ''; sessionDiffAggregator.ts ×1
353 > const content = await firstSource.db.readFileEditContent(identity.firstToolCallId, identity.firstFilePath); sessionDiffAggregator.ts ×1
354 > beforeText = content?.beforeContent ? new TextDecoder().decode(content.beforeContent) : '';
355 > }
357 > let afterText: string;
358 > if (identity.lastKind === FileEditKind.Delete) {
359 > afterText = ''; sessionDiffAggregator.ts ×1
361 > const content = await lastSource.db.readFileEditContent(identity.lastToolCallId, identity.lastFilePath);
362 > afterText = content?.afterContent ? new TextDecoder().decode(content.afterContent) : '';
363 > }
364 >
365 > if (beforeText === afterText) {
367 > }
369 > const counts = await diffService.computeDiffCounts(beforeText, afterText);
370 > results.push(createSessionFileDiff(firstSource.sessionUri, lastSource.sessionUri, identity, counts.added, counts.removed));
372 > }
373 >
374 > await Promise.allSettled(diffPromises);
375 >
376 > return results;
377 > }
379 > /**
380 > * Computes the diff statistics for a single turn — files touched only
381 > * within `turnId`, with their `before` snapshot taken from the first edit
382 > * record in that turn and their `after` snapshot from the last. Used by
383 > * the per-turn changeset (`<session>/changeset/turn/<turnId>`).
384 > *
385 > * Returns an empty array when the turn touched no files.
386 > */
387 export async function computeTurnDiffs(
388 sessionUri: string,
389 db: ISessionDatabase,
390 diffService: IDiffComputeService,
391 turnId: string,
392 ): Promise<ISessionFileDiff[]> {
393 const edits = await db.getFileEditsByTurn(turnId);
394 if (edits.length === 0) {
395 return [];
396 }
397
398 // Build identity graph for this turn only — same algorithm as
399 // `computeSessionDiffs` but scoped to a single turn's edits.
400 const pathToIdentityKey = new Map<string, string>();
401 const identities = new Map<string, IFileIdentity>();
402 for (const edit of edits) {
403 let identityKey: string;
404 if (edit.kind === FileEditKind.Rename && edit.originalPath) {
405 identityKey = pathToIdentityKey.get(edit.originalPath) ?? edit.originalPath;
406 pathToIdentityKey.set(edit.filePath, identityKey);
407 pathToIdentityKey.delete(edit.originalPath);
408 } else {
409 identityKey = pathToIdentityKey.get(edit.filePath) ?? edit.filePath;
410 pathToIdentityKey.set(edit.filePath, identityKey);
411 }
412 const existing = identities.get(identityKey);
413 if (!existing) {
414 identities.set(identityKey, {
415 terminalPath: edit.filePath,
416 firstToolCallId: edit.toolCallId,
417 firstFilePath: edit.kind === FileEditKind.Rename && edit.originalPath ? edit.originalPath : edit.filePath,
418 firstKind: edit.kind,
419 firstSourceIdx: 0,
420 lastToolCallId: edit.toolCallId,
421 lastFilePath: edit.filePath,
422 lastKind: edit.kind,
423 lastSourceIdx: 0,
424 });
425 } else {
426 existing.terminalPath = edit.filePath;
427 existing.lastToolCallId = edit.toolCallId;
428 existing.lastFilePath = edit.filePath;
429 existing.lastKind = edit.kind;
430 }
431 }
432
433 const results: ISessionFileDiff[] = [];
434 const diffPromises: Promise<void>[] = [];
435 for (const identity of identities.values()) {
436 diffPromises.push((async () => {
437 let beforeText: string;
438 if (identity.firstKind === FileEditKind.Create) {
439 beforeText = '';
440 } else {
441 const content = await db.readFileEditContent(identity.firstToolCallId, identity.firstFilePath);
442 beforeText = content?.beforeContent ? new TextDecoder().decode(content.beforeContent) : '';
443 }
444 let afterText: string;
445 if (identity.lastKind === FileEditKind.Delete) {
446 afterText = '';
447 } else {
448 const content = await db.readFileEditContent(identity.lastToolCallId, identity.lastFilePath);
449 afterText = content?.afterContent ? new TextDecoder().decode(content.afterContent) : '';
450 }
451 if (beforeText === afterText) {
452 return;
453 }
454 const counts = await diffService.computeDiffCounts(beforeText, afterText);
455 results.push(createSessionFileDiff(sessionUri, sessionUri, identity, counts.added, counts.removed));
456 })());
457 }
458 await Promise.allSettled(diffPromises);
459 return results;
460 }