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

1122 LOC · 691 covered · 431 uncovered · 155 ranges · 917 concepts · 27 introducers · 483 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 > /*--------------------------------------------------------------------------------------------- agentService.ts ×122
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 { disposableTimeout, SequencerByKey } from '../../../base/common/async.js';
7 > import { toErrorMessage } from '../../../base/common/errorMessage.js';
8 > import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js';
9 > import { URI } from '../../../base/common/uri.js';
10 > import { ILogService } from '../../log/common/log.js';
11 > import {
12 > buildBranchChangesetUri,
13 > buildCompareTurnsChangesetUri,
14 > buildSessionChangesetUri,
15 > buildTurnChangesetUri,
16 > buildUncommittedChangesetUri,
17 > parseChangesetUri,
18 > ChangesetKind,
19 > buildDefaultChangesetCatalog,
20 > } from '../common/changesetUri.js';
21 > import { IDiffComputeService } from '../common/diffComputeService.js';
22 > import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
23 > import type { ChangesetState, ChangesSummary } from '../common/state/protocol/state.js';
24 > import { ActionType } from '../common/state/sessionActions.js';
25 > import {
26 > ChangesetStatus,
27 > type ChangesetFile,
28 > type ISessionFileDiff,
29 > type URI as ProtocolURI,
30 > readSessionGitState,
31 > isDefaultChatUri,
32 > SessionLifecycle,
33 > } from '../common/state/sessionState.js';
34 > import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
35 > import { IAgentConfigurationService } from './agentConfigurationService.js';
36 > import { IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js';
37 > import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js';
38 > import { NodeWorkerDiffComputeService } from './diffComputeService.js';
39 > import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js';
40 > import { META_CHECKPOINT_WORKING_DIR } from './agentHostCheckpointService.js';
41 > import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS, StaticChangesetKind } from '../common/agentHostChangesetService.js';
42 > import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js';
43 > import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
44 > import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
45 > import { relativePath } from '../../../base/common/resources.js';
46 >
47 > function staticChangesetUri(session: ProtocolURI, kind: StaticChangesetKind): ProtocolURI { agentHostChangesetService.ts ×12
48 > return kind === 'branch'
49 > ? buildBranchChangesetUri(session)
50 > : buildSessionChangesetUri(session); agentHostChangesetService.ts ×25
53 > function persistKeyFor(kind: StaticChangesetKind): string { agentHostChangesetService.ts ×25
54 > return kind === 'branch'
55 ? META_CHANGESET_BRANCH
56 > : META_CHANGESET_SESSION; agentHostChangesetService.ts ×25
57 > }
59 > /**
60 > * Sums the per-file diff counts into the {@link ChangesSummary} shape
61 > * that lives on `summary.changes`. Returns `undefined` for an undefined
62 > * input so callers can distinguish "no data yet" from "data, zero changes".
63 > */
64 > function summariseDiffs(diffs: readonly ISessionFileDiff[] | undefined): ChangesSummary | undefined { agentHostChangesetService.ts ×6
65 > if (!diffs) {
66 > return undefined;
67 > }
68 let additions = 0;
69 let deletions = 0;
70 for (const d of diffs) {
71 additions += d.diff?.added ?? 0;
72 deletions += d.diff?.removed ?? 0;
73 }
74 return { additions, deletions, files: diffs.length };
75 }
77 > /**
78 > * Derives the `summary.changes` aggregate for an unopened session from
79 > * the ready live {@link ChangesetState} of the catalogue entry whose
80 > * `changeKind === 'session'` — typically because a previous
81 > * `restoreStaticChangeset` warmed the cache before the session itself
82 > * was attached.
83 > *
84 > * Returns `undefined` when no live session-wide state is ready, so
85 > * `listSessions` leaves the `changes` field unset for sessions without
86 > * usable counts — preserving the long-standing contract that unopened
87 > * sessions without live or persisted data advertise no aggregate.
88 > *
89 > * Only the `changeKind: 'session'` entry feeds the summary; other kinds
90 > * (`'uncommitted'`, `'turn'`, `'compare-turns'`) describe slices, not
91 > * the session-level footprint. The static catalogue itself (built by
92 > * {@link buildDefaultChangesetCatalog}) is independent of counts and
93 > * is seeded once at session creation.
94 > */
95 > function computeChangesSummaryFromLiveState( agentHostChangesetService.ts ×6
96 > session: ChangesetState | undefined,
97 > ): ChangesSummary | undefined {
98 > const sessionDiffs = session?.status === ChangesetStatus.Ready ? session.files.map(f => f.edit) : undefined;
99 > return summariseDiffs(sessionDiffs);
100 > }
102 > /**
103 > * Derives the `summary.changes` aggregate for an unopened session from
104 > * parsed persisted diffs for the `changeKind: 'session'` catalogue
105 > * entry. Returns `undefined` when the session-wide blob is absent so
106 > * malformed metadata leaves `summary.changes` unset.
107 > */
108 function computeChangesSummaryFromPersistedDiffs(
109 sessionDiffs: readonly ISessionFileDiff[] | undefined,
110 ): ChangesSummary | undefined {
111 return summariseDiffs(sessionDiffs);
112 }
114 > /**
115 > * Parses a JSON-serialised {@link ISessionFileDiff}[] blob from session
116 > * metadata. Returns `undefined` for missing or malformed input, logging a
117 > * warning that names `sessionUri` and `kind` so operators can correlate the
118 > * failure with a specific session/changeset slot. Never throws.
119 > */
120 > function tryParsePersistedDiffs(raw: string | undefined, sessionUri: string, kind: string, log: ILogService): ISessionFileDiff[] | undefined { agentService.ts ×13
121 > if (!raw) {
122 > return undefined;
123 > }
124 try {
125 return JSON.parse(raw) as ISessionFileDiff[];
126 } catch (err) {
127 log.warn(`[AgentHostChangesetService] Failed to parse persisted ${kind} diffs for ${sessionUri}: ${toErrorMessage(err)}`);
128 return undefined;
129 }
132 > export class AgentHostChangesetService extends Disposable implements IAgentHostChangesetService {
133 > declare readonly _serviceBrand: undefined;
134 >
135 > /** Shared diff compute service for calculating line-level diffs in a worker thread. */
136 > private readonly _diffComputeService: IDiffComputeService;
137 > /** Serializes per-session diff computations to avoid races with stale previousDiffs. */
138 > private readonly _diffComputationSequencer = new SequencerByKey<string>();
139 > /** Per-session debounce timers for mid-turn diff computation. */
140 > private readonly _debouncedDiffTimers = this._register(new DisposableMap<string>());
141 > /** Per-`(session, turnId)` debounce timers for mid-turn per-turn changeset recomputation. */
142 > private readonly _perTurnDebouncedDiffTimers = this._register(new DisposableMap<string>());
143 > private readonly _activeStaticComputes = new Set<ProtocolURI>();
144 > private static readonly _DIFF_DEBOUNCE_MS = 5000;
145 >
146 > /**
147 > * Sessions whose static changeset refresh was requested before the
148 > * working directory was known (provisional / not-yet-materialized
149 > * sessions). Drained from {@link onWorkingDirectoryAvailable} once the
150 > * working directory is set, which recomputes every changeset still
151 > * subscribed for the session.
152 > *
153 > * Firing a refresh before the working directory is known would compute
154 > * against a missing directory and the git path would bail, so we defer
155 > * instead and re-run once materialization / restore populates it.
156 > */
157 > private readonly _pendingMaterialization = new Set<ProtocolURI>();
158 >
159 > constructor(
160 > @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, agentService.ts ×10
161 > @ILogService private readonly _logService: ILogService,
162 > @ISessionDataService private readonly _sessionDataService: ISessionDataService,
163 > @IAgentHostGitService private readonly _gitService: IAgentHostGitService,
164 > @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService,
165 > @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
166 > @IAgentHostChangesetOperationService private readonly _changesetOperationService: IAgentHostChangesetOperationService,
167 > @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService,
168 > @IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService,
169 > ) {
170 > super();
171 > this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService));
172 > }
174 > /**
175 > * Returns true when at least one client is subscribed to `changeset`
176 > * under `session`.
177 > */
178 > private _hasSubscription(session: ProtocolURI, changeset: ProtocolURI): boolean {
179 > return this._changesetSubscriptions.getSessionSubscriptions(session).has(changeset); agentHostChangesetService.ts ×9
180 > }
182 > private _hasWorkingDirectory(session: ProtocolURI): boolean {
183 > return !!this._configurationService.getEffectiveWorkingDirectory(session); agentHostChangesetService.ts ×1
184 > }
186 > registerStaticChangesets(session: ProtocolURI): void {
187 > this._stateManager.registerChangeset(buildBranchChangesetUri(session)); agentHostChangesetService.ts ×3
188 > this._stateManager.registerChangeset(buildUncommittedChangesetUri(session));
189 > this._stateManager.registerChangeset(buildSessionChangesetUri(session));
190 > }
192 > restoreStaticChangeset(session: ProtocolURI, kind: StaticChangesetKind, diffs: readonly ISessionFileDiff[]): void {
193 const changesetUri = this._stateManager.registerChangeset(staticChangesetUri(session, kind));
194 this._publishChangesetDiffs(session, changesetUri, diffs);
195 }
197 > parsePersistedStaticChangesets(sessionUri: ProtocolURI, metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs {
198 > const persistedBranch = tryParsePersistedDiffs(metadata.branchRaw, sessionUri, 'branch', this._logService); agentService.ts ×13
199 >
200 > // Legacy `diffs` is the migration fallback for the session-wide
201 > // changeset only — it never carried uncommitted state.
202 > const persistedSession = tryParsePersistedDiffs(metadata.sessionRaw, sessionUri, 'session', this._logService)
203 > ?? tryParsePersistedDiffs(metadata.legacyRaw, sessionUri, 'session (legacy)', this._logService);
204 >
205 > return { branch: persistedBranch, session: persistedSession };
206 > }
208 > applyPersistedStaticChangesets(sessionUri: ProtocolURI, diffs: IRestoredChangesetDiffs): void {
209 > // `seedIfEmpty`: only reseed persisted diffs when the matching live agentService.ts ×13
210 > // changeset state is absent or empty. Live state (e.g. from a prior
211 > // refresh in this lifetime) is always more authoritative than a
212 > // potentially-stale persisted blob; without this guard a fresh
213 > // `restorePersistedStaticChangesets` call would clobber it.
214 > this._seedIfEmpty(sessionUri, 'branch', diffs.branch);
215 > this._seedIfEmpty(sessionUri, 'session', diffs.session);
216 > }
218 > restorePersistedStaticChangesets(sessionUri: ProtocolURI, metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs {
219 > const parsed = this.parsePersistedStaticChangesets(sessionUri, metadata); agentService.ts ×13
220 > this.applyPersistedStaticChangesets(sessionUri, parsed);
221 > return parsed;
222 > }
224 > persistChangesSummary(sessionUri: ProtocolURI, summary: ChangesSummary): void {
225 this._persistSessionFlag(sessionUri, META_CHANGES_SUMMARY, JSON.stringify(summary));
226 }
228 > getListMetadataKeys(sessionUri: ProtocolURI): Record<string, true> | undefined {
229 > // Fast path: a live `summary.changes` (loaded session) or a ready live agentService.ts ×11
230 > // `changeKind: 'session'` changeset state (registered but not-yet-
231 > // restored session) is authoritative, so the caller can skip loading
232 > // the potentially-large persisted diff blobs.
233 > const liveSummaryChanges = this._stateManager.getSessionSummary(sessionUri)?.changes;
234 > if (liveSummaryChanges) {
235 return undefined;
236 }
237 > const liveSession = this._stateManager.getChangesetState(buildSessionChangesetUri(sessionUri)); agentService.ts ×11
238 > if (liveSession?.status === ChangesetStatus.Ready) {
239 return undefined;
240 }
241 > return CHANGESET_DB_METADATA_KEYS; agentService.ts ×11
242 > }
244 > computeListEntryChanges(sessionUri: ProtocolURI, metadata: Record<string, string | undefined>): ChangesSummary | undefined {
245 > // Loaded session: the caller has already projected agentService.ts ×11
246 > // `state.summary.changes` onto the entry. Nothing to overlay.
247 > if (this._stateManager.getSessionState(sessionUri)) {
248 > return undefined; agentService.ts ×5
249 > }
251 > // Check if the metadata contains the changes summary. In the past we
252 > // used to store the changesets in the session database but we have
253 > // since moved to a more efficient storage mechanism by only storing
254 > // the changes summary.
255 > const changesSummary = metadata[META_CHANGES_SUMMARY];
256 > if (changesSummary !== undefined) {
257 try {
258 return JSON.parse(changesSummary) as ChangesSummary;
259 } catch (error) {
260 return undefined;
261 }
262 }
264 > // Read live state for an unopened session: synthesise the aggregate
265 > // from the live `changeKind: 'branch'` changeset state. Counts stay
266 > // in lockstep with the actual changeset state for the session-list chip.
267 > const liveSession = this._stateManager.getChangesetState(buildBranchChangesetUri(sessionUri));
268 > const liveChanges = computeChangesSummaryFromLiveState(liveSession);
269 > if (liveChanges) {
270 // Migrate the changes summary to the new storage mechanism.
271 this.persistChangesSummary(sessionUri, liveChanges);
272 return liveChanges;
273 }
275 > // No live source — try persisted blobs (if the caller batched them).
276 > const branchRaw = metadata[META_CHANGESET_BRANCH];
277 > const legacyRaw = metadata[META_LEGACY_DIFFS];
278 > if (branchRaw === undefined && legacyRaw === undefined) { agentService.ts ×11
279 > return undefined; agentHostChangesetService.ts ×6
280 > }
281 const restored = this.parsePersistedStaticChangesets(sessionUri, { branchRaw, legacyRaw });
282
283 // `listSessions` must not seed full changeset state for every row; it
284 // only parses persisted blobs enough to render the chip aggregate.
285 // Once the session is opened via `restoreSession`, the live overlay in
286 // `AgentService.listSessions` replaces this parse-only aggregate.
287 const persistedChanges = computeChangesSummaryFromPersistedDiffs(restored.branch);
288 if (persistedChanges) {
289 // Migrate the changes summary to the new storage mechanism.
290 this.persistChangesSummary(sessionUri, persistedChanges);
291 return persistedChanges;
292 }
293
294 return undefined;
297 > isStaticChangesetComputeActive(changesetUri: ProtocolURI): boolean {
298 return this._activeStaticComputes.has(changesetUri);
299 }
301 > private _seedIfEmpty(session: ProtocolURI, kind: StaticChangesetKind, diffs: readonly ISessionFileDiff[] | undefined): void {
302 > if (!diffs) { agentService.ts ×13
303 > return;
304 > }
305 const existing = this._stateManager.getChangesetState(staticChangesetUri(session, kind));
306 > if (existing && existing.files.length > 0) { agentService.ts ×13
307 return;
308 }
309 this.restoreStaticChangeset(session, kind, diffs);
312 > refreshChangesetCatalog(session: ProtocolURI): void {
313 > const state = this._stateManager.getSessionState(session); agentHostChangesetService.ts ×3
314 > if (!state || state?.lifecycle === SessionLifecycle.CreationFailed) {
315 return;
316 }
318 > const changesets = buildDefaultChangesetCatalog(session, state);
319 > this._stateManager.setSessionChangesets(session, changesets);
320 > }
322 > refreshBranchChangeset(session: ProtocolURI): void {
323 > if (!this._hasWorkingDirectory(session)) { agentHostChangesetService.ts ×2
324 > this._pendingMaterialization.add(session); agentHostChangesetService.ts ×1
325 > return;
326 > }
327 > this._scheduleStaticRecompute(session, 'branch', undefined, this._markStaticChangesetComputing(session, 'branch')); agentHostChangesetService.ts ×12
330 > refreshSessionChangeset(session: ProtocolURI): void {
331 > if (!this._hasWorkingDirectory(session)) { agentHostChangesetService.ts ×2
332 > this._pendingMaterialization.add(session); agentHostChangesetService.ts ×1
333 > return;
334 > }
335 > this._scheduleStaticRecompute(session, 'session', undefined, this._markStaticChangesetComputing(session, 'session')); agentHostChangesetService.ts ×25
338 > /**
339 > * Drains static changeset refreshes that were deferred because the
340 > * session's working directory was not yet known. Called by the
341 > * coordinator once a session is materialized or restored. Recomputes
342 > * every changeset still subscribed for the session; subscriptions that
343 > * dropped while the working directory was unknown are naturally skipped.
344 > */
345 > onWorkingDirectoryAvailable(session: ProtocolURI): void {
346 > if (this._pendingMaterialization.delete(session)) { agentService.ts ×13
347 > this.recomputeSubscribedChangesets(session); agentHostChangesetService.ts ×1
348 > }
351 > /**
352 > * Recomputes every changeset currently subscribed for `session`. Each
353 > * subscribed changeset is dispatched to its kind-specific recompute; the
354 > * recomputes self-defer when the working directory is still unknown.
355 > */
356 > recomputeSubscribedChangesets(session: ProtocolURI): void {
357 > const subscriptions = this._changesetSubscriptions.getSessionSubscriptions(session); agentHostChangesetService.ts ×2
358 > if (subscriptions.size === 0) {
360 > }
361 > for (const changeset of subscriptions) { agentHostChangesetService.ts ×6
362 > const parsed = parseChangesetUri(changeset);
363 > switch (parsed?.kind) {
364 > case ChangesetKind.Branch:
365 > this.refreshBranchChangeset(session); agentHostChangesetService.ts ×1
366 > break;
367 > case ChangesetKind.Session: agentHostChangesetService.ts ×6
368 this.refreshSessionChangeset(session);
369 break;
370 > case ChangesetKind.Uncommitted: agentHostChangesetService.ts ×6
371 > void this.computeUncommittedChangeset(session); agentHostChangesetService.ts ×1
372 > break;
373 > case ChangesetKind.Turn: agentHostChangesetService.ts ×6
374 if (parsed.turnId !== undefined) {
375 void this.computeTurnChangeset(session, parsed.turnId);
376 }
377 break;
379 // A plain session URI subscription (Agents Window list /
380 // detail observing the session) implicitly observes the
381 // catalogue's static changesets — refresh both.
382 if (changeset === session) {
383 this.refreshBranchChangeset(session);
384 this.refreshSessionChangeset(session);
385 }
386 break;
388 > }
391 > /**
392 > * Forgets any deferred static changeset refreshes queued for a session
393 > * that is being disposed.
394 > */
395 > onSessionDisposed(session: ProtocolURI): void {
396 > this._pendingMaterialization.delete(session); agentService.ts ×2
397 > }
399 > async computeTurnChangeset(session: ProtocolURI, turnId: string): Promise<ProtocolURI> {
400 const turnUri = this._stateManager.registerChangeset(buildTurnChangesetUri(session, turnId));
401 let ref: ReturnType<ISessionDataService['openDatabase']>;
402 try {
403 ref = this._sessionDataService.openDatabase(URI.parse(session));
404 } catch (err) {
405 this._logService.warn(`[AgentHostChangesetService] Failed to open session database for turn diff: ${session}`, err);
406 this._stateManager.dispatchServerAction(turnUri, {
407 type: ActionType.ChangesetStatusChanged,
408 status: ChangesetStatus.Error,
409 error: { errorType: 'computeFailed', message: err instanceof Error ? err.message : String(err) },
410 });
411 return turnUri;
412 }
413 try {
414 // Prefer the checkpoint-ref git diff when available — that path
415 // captures terminal-tool edits the FileEditTracker pipeline
416 // (`file_edits` rows) misses. Falls back to the SDK-tracked
417 // aggregator when checkpoints aren't set up (non-git folder
418 // isolation, baseline never captured, or capture failure).
419 const diffs = await this._computeTurnDiffsPreferCheckpoint(session, ref.object, turnId);
420 this._publishChangesetDiffs(session, turnUri, diffs);
421 } catch (err) {
422 this._logService.warn(`[AgentHostChangesetService] Failed to compute turn diffs for ${session}/${turnId}`, err);
423 this._stateManager.dispatchServerAction(turnUri, {
424 type: ActionType.ChangesetStatusChanged,
425 status: ChangesetStatus.Error,
426 error: { errorType: 'computeFailed', message: err instanceof Error ? err.message : String(err) },
427 });
428 } finally {
429 ref.dispose();
430 }
431 return turnUri;
432 }
434 > async computeCompareTurnsChangeset(session: ProtocolURI, originalTurnId: string, modifiedTurnId: string): Promise<ProtocolURI> {
435 const compareUri = this._stateManager.registerChangeset(buildCompareTurnsChangesetUri(session, originalTurnId, modifiedTurnId));
436 let ref: ReturnType<ISessionDataService['openDatabase']>;
437 try {
438 ref = this._sessionDataService.openDatabase(URI.parse(session));
439 } catch (err) {
440 this._logService.warn(`[AgentHostChangesetService] Failed to open session database for compare-turns diff: ${session}`, err);
441 this._stateManager.dispatchServerAction(compareUri, {
442 type: ActionType.ChangesetStatusChanged,
443 status: ChangesetStatus.Error,
444 error: { errorType: 'computeFailed', message: err instanceof Error ? err.message : String(err) },
445 });
446 return compareUri;
447 }
448 try {
449 const sessionUri = URI.parse(session);
450 const [originalCurrentRef, modifiedPair] = await Promise.all([
451 this._checkpointService.getTurnCheckpointPair(sessionUri, originalTurnId).then(p => p?.current),
452 this._checkpointService.getTurnCheckpointPair(sessionUri, modifiedTurnId),
453 ]);
454 if (!originalCurrentRef || !modifiedPair) {
455 // One of the turns has no checkpoint — either it's an
456 // unknown id, the session isn't git-backed, or the
457 // baseline / capture failed. No edit-tracker fallback
458 // exists for between-two-turns comparisons.
459 const missing = !originalCurrentRef && !modifiedPair
460 ? 'both turns'
461 : !originalCurrentRef
462 ? 'original turn'
463 : 'modified turn';
464 this._stateManager.dispatchServerAction(compareUri, {
465 type: ActionType.ChangesetStatusChanged,
466 status: ChangesetStatus.Error,
467 error: { errorType: 'computeFailed', message: `No checkpoint available for ${missing}; compare requires git-backed sessions.` },
468 });
469 return compareUri;
470 }
471 if (originalCurrentRef === modifiedPair.current) {
472 // Same endpoint on both sides — diff is empty by
473 // construction (covers compare(turn, turn) and the no-op
474 // turn case where two adjacent turns share a ref).
475 this._publishChangesetDiffs(session, compareUri, []);
476 return compareUri;
477 }
478 const workingDir = await this._resolveWorkingDirectory(ref.object);
479 if (!workingDir) {
480 this._stateManager.dispatchServerAction(compareUri, {
481 type: ActionType.ChangesetStatusChanged,
482 status: ChangesetStatus.Error,
483 error: { errorType: 'computeFailed', message: 'No working directory recorded for session; compare requires git-backed sessions.' },
484 });
485 return compareUri;
486 }
487 const diffs = await this._gitService.computeFileDiffsBetweenRefs(workingDir, {
488 sessionUri: session,
489 fromRef: originalCurrentRef,
490 toRef: modifiedPair.current,
491 });
492 if (diffs === undefined) {
493 // `computeFileDiffsBetweenRefs` returns undefined to signal a
494 // git failure (not a git work tree, bad ref, transport error,
495 // etc.) and an empty array to signal "no changes". Collapsing
496 // both into [] would mask real failures as an empty Ready
497 // snapshot — surface the failure explicitly instead.
498 this._stateManager.dispatchServerAction(compareUri, {
499 type: ActionType.ChangesetStatusChanged,
500 status: ChangesetStatus.Error,
501 error: { errorType: 'computeFailed', message: `Failed to compute compare-turns diff from git (${originalCurrentRef}..${modifiedPair.current}).` },
502 });
503 return compareUri;
504 }
505 this._publishChangesetDiffs(session, compareUri, diffs);
506 } catch (err) {
507 this._logService.warn(`[AgentHostChangesetService] Failed to compute compare-turns diffs for ${session}/${originalTurnId}/${modifiedTurnId}`, err);
508 this._stateManager.dispatchServerAction(compareUri, {
509 type: ActionType.ChangesetStatusChanged,
510 status: ChangesetStatus.Error,
511 error: { errorType: 'computeFailed', message: err instanceof Error ? err.message : String(err) },
512 });
513 } finally {
514 ref.dispose();
515 }
516 return compareUri;
517 }
519 > async computeUncommittedChangeset(session: ProtocolURI): Promise<ProtocolURI> {
520 > const uncommittedUri = this._stateManager.registerChangeset(buildUncommittedChangesetUri(session)); agentHostChangesetService.ts ×9
521 > if (!this._hasSubscription(session, uncommittedUri)) {
522 return uncommittedUri;
523 }
525 > // Defer until the working directory is known. Computing now would bail
526 > // in the git path (there is no SDK edit-tracker fallback for the
527 > // uncommitted slot); `onWorkingDirectoryAvailable` re-runs the refresh
528 > // once materialization / restore populates the directory.
529 > if (!this._hasWorkingDirectory(session)) {
530 > this._pendingMaterialization.add(session); agentHostChangesetService.ts ×2
531 > return uncommittedUri;
532 > }
534 > const statusBeforeCompute = this._stateManager.getChangesetState(uncommittedUri)?.status;
535 > if (statusBeforeCompute !== ChangesetStatus.Computing) {
536 > this._stateManager.dispatchServerAction(uncommittedUri, { agentHostChangesetService.ts ×2
537 > type: ActionType.ChangesetStatusChanged,
538 > status: ChangesetStatus.Computing,
539 > });
540 > }
542 > try {
543 > const diffs = await this._computeUncommittedDiffs(session);
544 > if (diffs === undefined) {
545 > // Git unavailable (no working directory, not a git work agentHostChangesetService.ts ×1
546 > // tree, or the git command returned nothing). Surface as
547 > // Error rather than preserving cached state — no SDK
548 > // edit-tracker fallback exists for the uncommitted slot.
549 > this._stateManager.dispatchServerAction(uncommittedUri, {
550 > type: ActionType.ChangesetStatusChanged,
551 > status: ChangesetStatus.Error,
552 > error: { errorType: 'computeFailed', message: 'Failed to compute uncommitted diff from git.' },
553 > });
554 > return uncommittedUri;
555 > }
557 > this._publishChangesetDiffs(session, uncommittedUri, diffs);
558 > } catch (err) {
559 this._logService.warn(`[AgentHostChangesetService] Failed to compute uncommitted diffs for ${session}`, err);
560 this._stateManager.dispatchServerAction(uncommittedUri, {
561 type: ActionType.ChangesetStatusChanged,
562 status: ChangesetStatus.Error,
563 error: { errorType: 'computeFailed', message: err instanceof Error ? err.message : String(err) },
564 });
565 }
567 > return uncommittedUri;
570 > private async _computeUncommittedDiffs(session: ProtocolURI): Promise<readonly ISessionFileDiff[] | undefined> {
571 > const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; agentHostChangesetService.ts ×9
572 > if (!workingDirectory) {
573 return undefined;
574 }
576 > let workingDirectoryUri: URI;
577 > try {
578 > workingDirectoryUri = URI.parse(workingDirectory);
579 > } catch {
580 return undefined;
581 }
583 > return this._gitService.computeSessionFileDiffs(workingDirectoryUri, {
584 > sessionUri: session,
585 > });
586 > }
588 > private async _computeTurnDiffsPreferCheckpoint(session: ProtocolURI, db: ISessionDatabase, turnId: string): Promise<readonly ISessionFileDiff[]> {
589 const pair = await this._checkpointService.getTurnCheckpointPair(URI.parse(session), turnId);
590 if (pair && pair.parent !== pair.current) {
591 const workingDir = await this._resolveWorkingDirectory(db);
592 if (workingDir) {
593 const fromRefDiffs = await this._gitService.computeFileDiffsBetweenRefs(workingDir, {
594 sessionUri: session,
595 fromRef: pair.parent,
596 toRef: pair.current,
597 });
598 if (fromRefDiffs) {
599 return fromRefDiffs;
600 }
601 }
602 } else if (pair && pair.parent === pair.current) {
603 // A no-op turn checkpoint reuses the parent ref (so per-turn
604 // diff is empty by construction) — short-circuit to an empty
605 // list instead of asking git for the (empty) diff.
606 return [];
607 }
608 // Fallback: SDK-tracked file_edits aggregator.
609 return computeTurnDiffs(session, db, this._diffComputeService, turnId);
610 }
612 > private async _resolveWorkingDirectory(db: ISessionDatabase): Promise<URI | undefined> {
613 // Checkpoint baseline writes `checkpoint.workingDir` alongside
614 // `checkpoint.baseRef`. We use that as the canonical working
615 // directory for checkpoint diff computation; reading it here keeps
616 // the changeset service out of agent-specific metadata keys.
617 const raw = await db.getMetadata(META_CHECKPOINT_WORKING_DIR);
618 return raw ? URI.parse(raw) : undefined;
619 }
621 > // ---- Lifecycle hooks invoked by AgentSideEffects -----------------------
622 >
623 > onToolCallEditsApplied(session: ProtocolURI, turnId: string): void {
624 this._scheduleDebouncedDiffComputation(session, turnId);
625 // Per-turn URIs have no catalogue chip aggregates, so skip the
626 // recompute entirely when no client is observing this turn. The
627 // next subscriber will get a fresh snapshot from
628 // `tryHandleSubscribe → computeTurnChangeset`.
629 if (this._hasSubscription(session, buildTurnChangesetUri(session, turnId))) {
630 this._scheduleDebouncedTurnDiffComputation(session, turnId);
631 }
632 }
634 > onTurnComplete(session: ProtocolURI, turnId: string | undefined): void {
635 // Ordering matters for cancellation: cancel any pending mid-turn
636 // debounces first so the final turn-complete computes supersede
637 // them. After that, schedule the final recomputes for the turn
638 // (when observed), the session-wide changeset with the changed
639 // turn id, and the uncommitted changeset when it is observed.
640 this._cancelDebouncedDiffComputation(session);
641 if (turnId !== undefined) {
642 this._cancelDebouncedTurnDiffComputation(session, turnId);
643 if (this._hasSubscription(session, buildTurnChangesetUri(session, turnId))) {
644 this._scheduleTurnRecompute(session, turnId);
645 }
646 }
647
648 if (this._hasSubscription(session, buildUncommittedChangesetUri(session))) {
649 this._scheduleUncommittedRecompute(session);
650 }
651
652 this._scheduleStaticRecompute(session, 'branch', turnId);
653 this._scheduleStaticRecompute(session, 'session', turnId);
654 }
656 > onSessionTruncated(session: ProtocolURI): void {
657 // Turns were removed — recompute from scratch (no changedTurnId).
658 this._scheduleStaticRecompute(session, 'branch');
659 this._scheduleStaticRecompute(session, 'session');
660 }
662 > // ---- Internal compute pipeline -----------------------------------------
663 >
664 > /**
665 > * Schedules a debounced session-changeset recomputation. Uncommitted
666 > * recomputes ride the same turn-complete path; mid-turn debounce only
667 > * makes sense for the SDK-tracked session-wide diff (which sees fresh
668 > * `tool_complete` events between turn boundaries).
669 > */
670 > private _scheduleDebouncedDiffComputation(session: ProtocolURI, turnId: string): void {
671 this._debouncedDiffTimers.set(session, disposableTimeout(() => {
672 this._debouncedDiffTimers.deleteAndDispose(session);
673 this._scheduleStaticRecompute(session, 'branch', turnId);
674 this._scheduleStaticRecompute(session, 'session', turnId);
675 }, AgentHostChangesetService._DIFF_DEBOUNCE_MS));
676 }
678 > /**
679 > * Cancels any pending debounced diff computation for a session.
680 > * Called at turn end before the final (non-debounced) computation.
681 > */
682 > private _cancelDebouncedDiffComputation(session: ProtocolURI): void {
683 this._debouncedDiffTimers.deleteAndDispose(session);
684 }
686 > /**
687 > * Schedules a debounced per-turn changeset recomputation. Mirrors
688 > * {@link _scheduleDebouncedDiffComputation} but uses a per-
689 > * `(session, turnId)` map key so a long-running per-turn compute
690 > * doesn't block the static session recompute path (and vice versa).
691 > */
692 > private _scheduleDebouncedTurnDiffComputation(session: ProtocolURI, turnId: string): void {
693 const key = `${session}\u0000${turnId}`;
694 this._perTurnDebouncedDiffTimers.set(key, disposableTimeout(() => {
695 this._perTurnDebouncedDiffTimers.deleteAndDispose(key);
696 this._scheduleTurnRecompute(session, turnId);
697 }, AgentHostChangesetService._DIFF_DEBOUNCE_MS));
698 }
700 > /**
701 > * Cancels any pending debounced per-turn diff computation for a
702 > * `(session, turnId)`. Called at turn end before the final
703 > * (non-debounced) per-turn computation.
704 > */
705 > private _cancelDebouncedTurnDiffComputation(session: ProtocolURI, turnId: string): void {
706 this._perTurnDebouncedDiffTimers.deleteAndDispose(`${session}\u0000${turnId}`);
707 }
709 > /**
710 > * Queues a per-turn recompute on a per-`(session, turnId)` sequencer
711 > * key so back-to-back recomputes for the same turn serialise, but
712 > * recomputes for different turns (or for the static `session` /
713 > * `uncommitted` slots) run independently. Fire-and-forget — failures
714 > * are logged inside `computeTurnChangeset` and do not fail the turn.
715 > */
716 > private _scheduleTurnRecompute(session: ProtocolURI, turnId: string): void {
717 this._diffComputationSequencer.queue(`${session}\u0000turn\u0000${turnId}`, () => this.computeTurnChangeset(session, turnId).then(() => undefined));
718 }
720 > private _scheduleUncommittedRecompute(session: ProtocolURI): void {
721 this._diffComputationSequencer.queue(`${session}\u0000uncommitted`, () => this.computeUncommittedChangeset(session).then(() => undefined));
722 }
724 > /**
725 > * Schedules a static changeset (`uncommitted` or `session`) recompute,
726 > * serialised per-session so back-to-back triggers don't race against
727 > * stale `previousDiffs` reads. Fire-and-forget — failures are logged
728 > * but do not fail the turn.
729 > */
730 > private _scheduleStaticRecompute(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus): void {
731 > this._diffComputationSequencer.queue(`${session}\u0000${kind}`, () => this._doComputeStaticChangeset(session, kind, changedTurnId, statusBeforeRefresh)); agentHostChangesetService.ts ×12
732 > }
734 > private _markStaticChangesetComputing(session: ProtocolURI, kind: StaticChangesetKind): ChangesetStatus | undefined {
735 > const changesetUri = staticChangesetUri(session, kind); agentHostChangesetService.ts ×12
736 > this._stateManager.registerChangeset(changesetUri);
737 > const status = this._stateManager.getChangesetState(changesetUri)?.status;
738 > if (status !== ChangesetStatus.Computing) {
739 this._stateManager.dispatchServerAction(changesetUri, {
740 type: ActionType.ChangesetStatusChanged,
741 status: ChangesetStatus.Computing,
742 });
743 }
744 > return status; agentHostChangesetService.ts ×12
745 > }
747 > private async _doComputeStaticChangeset(session: ProtocolURI, kind: StaticChangesetKind, changedTurnId?: string, statusBeforeRefresh?: ChangesetStatus): Promise<void> {
748 > const changesetUri = staticChangesetUri(session, kind); agentHostChangesetService.ts ×12
749 > this._activeStaticComputes.add(changesetUri);
750 > const statusBeforeCompute = statusBeforeRefresh ?? this._stateManager.getChangesetState(changesetUri)?.status;
751 > let ref: ReturnType<ISessionDataService['openDatabase']>;
752 > try {
753 > ref = this._sessionDataService.openDatabase(URI.parse(session));
754 > } catch (err) {
755 > this._logService.warn(`[AgentHostChangesetService] Failed to open session database for ${kind} diff computation: ${session}`, err); agentHostChangesetService.ts ×1
756 > this._restoreStaticChangesetStatus(changesetUri, statusBeforeCompute);
757 > this._activeStaticComputes.delete(changesetUri);
758 > this._stateManager.onChangesetLivenessChanged();
759 > return;
760 > }
761 > this._stateManager.registerChangeset(changesetUri); agentHostChangesetService.ts ×25
762 > try {
763 > let diffs = await this._tryComputeGitDiffs(session, ref.object, kind);
764 > if (!diffs) {
765 > if (kind === 'branch') {
766 > // Branch changeset answers a different question than the
767 > // edit-tracker aggregator — do not fall back. Preserve
768 > // whatever cached state is already there.
769 > this._logService.debug(`[AgentHostChangesetService] Branch git diff unavailable for ${session}; preserving cached changeset. previousStatus=${statusBeforeCompute ?? 'unknown'} cachedFiles=${this._stateManager.getChangesetState(changesetUri)?.files.length ?? 0}`);
770 > this._restoreStaticChangesetStatus(changesetUri, statusBeforeCompute);
771 > return;
772 > }
773 > // `session` kind: working-tree git is unavailable (no
774 > // working dir or not a git work tree). Fall back to the
775 > // edit-tracker aggregator — for the session changeset the
776 > // SDK-tracked edits are the best available approximation.
777 > //
778 > // In multi-chat sessions each peer chat records its file
779 > // edits into its OWN database (the chat URI is used as the
780 > // session URI for that chat's edit tracker). Union the
781 > // session DB with every peer chat DB so peer-chat edits roll
782 > // up into the session-level changes.
783 > const peerSources = this._openPeerChatSources(session);
784 > try {
785 > if (peerSources.length > 0) {
786 const sources: ISessionDiffSource[] = [
787 { sessionUri: session, db: ref.object },
788 ...peerSources.map(p => ({ sessionUri: p.sessionUri, db: p.ref.object })),
789 ];
790 // TODO (debt): multi-chat always does a full recompute
791 // (the incremental `changedTurnId`/`previousDiffs` path is
792 // only used for single-chat below). A follow-up can make
793 // `computeUnionedDiffs` incremental — see its doc comment
794 // and the tracking issue.
795 diffs = await computeUnionedDiffs(sources, this._diffComputeService);
797 > let incremental: IIncrementalDiffOptions | undefined;
798 > if (changedTurnId) {
799 const previousDiffs = this._readPreviousChangesetDiffs(changesetUri);
800 if (previousDiffs) {
801 incremental = { changedTurnId, previousDiffs: [...previousDiffs] };
802 }
803 }
804 > diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental); agentHostChangesetService.ts ×25
805 > }
806 > } finally {
807 > for (const peer of peerSources) {
808 peer.ref.dispose();
809 }
811 > }
812 >
813 > const reviewed = kind === ChangesetKind.Branch
814 ? await this._computeReviewedInfo(session, ref.object)
816 > this._publishChangesetDiffs(session, changesetUri, diffs, reviewed); agentHostChangesetService.ts ×12
817 >
818 > // Persist the file list so a subsequent `listSessions` /
819 > // `restoreSession` can reseed the changeset before the first
820 > // post-restart compute completes.
821 > this._persistSessionFlag(session, persistKeyFor(kind), JSON.stringify(diffs));
822 >
823 > if (kind === ChangesetKind.Branch) {
824 // Migration: also overwrite the legacy `'diffs'` key with the
825 // session-changeset payload so older readers stay correct
826 // during the rollout window.
827 this._persistSessionFlag(session, META_LEGACY_DIFFS, JSON.stringify(diffs));
828
829 // Persist the changes summary and update the in-memory session
830 // summary from the BRANCH changeset. The session-list chip and the
831 // inactive-session aggregate (`computeListEntryChanges`) read the
832 // branch changeset, as does the active session view, so sourcing
833 // the persisted summary from the same place keeps the count stable
834 // across the active <-> inactive transition instead of flipping to
835 // the (different) session changeset's count.
836 const changesSummary = summariseDiffs(diffs) ?? { additions: 0, deletions: 0, files: 0 };
837 this.persistChangesSummary(session, changesSummary);
838 this._stateManager.setSessionSummaryChanges(session, changesSummary);
839 }
840 > } catch (err) { agentHostChangesetService.ts ×12
841 this._logService.warn(`[AgentHostChangesetService] Failed to compute ${kind} diffs`, err);
842 this._stateManager.dispatchServerAction(changesetUri, {
843 type: ActionType.ChangesetStatusChanged,
844 status: ChangesetStatus.Error,
845 error: { errorType: 'computeFailed', message: err instanceof Error ? err.message : String(err) },
846 });
848 > this._activeStaticComputes.delete(changesetUri);
849 > this._stateManager.onChangesetLivenessChanged();
850 > ref.dispose();
851 > }
854 > /**
855 > * Refresh requests optimistically mark static changesets as Computing
856 > * while preserving their current files. Some refresh paths intentionally
857 > * do not publish a replacement file list (for example, uncommitted git
858 > * diff is temporarily unavailable), so restore the previous non-computing
859 > * status instead of leaving a stale cached snapshot stuck as Computing.
860 > */
861 > private _restoreStaticChangesetStatus(changesetUri: ProtocolURI, status: ChangesetStatus | undefined): void {
862 > if (!status || status === ChangesetStatus.Computing) { agentHostChangesetService.ts ×12
863 > return;
864 > }
865 this._stateManager.dispatchServerAction(changesetUri, {
866 type: ActionType.ChangesetStatusChanged,
867 status,
868 });
871 > /**
872 > * Reads the previous diff list back out of the changeset state so the
873 > * incremental aggregator can avoid recomputing files that haven't
874 > * changed.
875 > */
876 > private _readPreviousChangesetDiffs(changesetUri: ProtocolURI): readonly ISessionFileDiff[] | undefined {
877 const state = this._stateManager.getChangesetState(changesetUri);
878 if (!state || state.files.length === 0) {
879 return undefined;
880 }
881 return state.files.map(f => f.edit);
882 }
884 > /**
885 > * Translates the new file list into a sequence of changeset/* actions
886 > * (fileSet, fileRemoved) and moves the changeset to `ready` once the
887 > * fresh file list has been applied.
888 > */
889 > private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[], reviewed?: { readonly repoRoot: URI; readonly paths: ReadonlySet<string> }): void {
890 > // Get the available operations for this changeset. This call assumes that at this point agentHostChangesetOperationService.ts ×3
891 > // the git state of the session is up-to-date as it is being used to determine the available
892 > // operations. Long term this should be replaced with a more robust mechanism.
893 > const operations = this._changesetOperationService.getOperations(session, changesetUri);
894 >
895 > const files: ChangesetFile[] = [];
896 > for (const edit of diffs) {
897 const id = edit.after?.uri ?? edit.before?.uri;
898 if (!id) {
899 continue;
900 }
901 if (reviewed) {
902 const relPath = relativePath(reviewed.repoRoot, URI.parse(id));
903 files.push({
904 id, edit,
905 reviewed: relPath
906 ? reviewed.paths.has(relPath)
907 : false
908 });
909 } else {
910 files.push({ id, edit });
911 }
912 }
914 > this._stateManager.dispatchServerAction(changesetUri, {
915 > type: ActionType.ChangesetContentChanged,
916 > files,
917 > operations: operations
918 > ? [...operations]
919 : undefined,
921 >
922 > // Move the changeset out of `computing` (or out of an earlier error)
923 > // now that we have a fresh, complete file list.
924 > const status = this._stateManager.getChangesetState(changesetUri)?.status;
925 > if (status !== ChangesetStatus.Ready) {
926 > this._stateManager.dispatchServerAction(changesetUri, {
927 > type: ActionType.ChangesetStatusChanged,
928 > status: ChangesetStatus.Ready,
929 > });
930 > }
931 > }
933 > /**
934 > * Opens the databases for every non-default (peer) chat in a multi-chat
935 > * session. Each peer chat records its file edits into its own database
936 > * keyed by the chat URI, so the session changeset must union those
937 > * databases with the session DB. Returns an empty array for single-chat
938 > * sessions. Callers MUST dispose every returned `ref`.
939 > */
940 > private _openPeerChatSources(session: ProtocolURI): { sessionUri: ProtocolURI; ref: ReturnType<ISessionDataService['openDatabase']> }[] {
941 > const chats = this._stateManager.getSessionState(session)?.chats ?? []; agentHostChangesetService.ts ×25
942 > const sources: { sessionUri: ProtocolURI; ref: ReturnType<ISessionDataService['openDatabase']> }[] = [];
943 > for (const chat of chats) {
944 > if (isDefaultChatUri(chat.resource)) {
945 > continue;
946 > }
947 try {
948 const ref = this._sessionDataService.openDatabase(URI.parse(chat.resource));
949 sources.push({ sessionUri: chat.resource, ref });
950 } catch (err) {
951 this._logService.warn(`[AgentHostChangesetService] Failed to open peer chat database for session changes: ${chat.resource}`, err);
952 }
954 > return sources;
955 > }
957 > /**
958 > * Returns the turn id whose checkpoint best represents the latest state of
959 > * the session's shared working tree. For single-chat sessions this is the
960 > * default chat's last turn. For multi-chat sessions it is the last turn of
961 > * the most-recently-modified chat (peer-chat turn checkpoints are stored
962 > * under the session URI keyed by their turn id). Returns `undefined` when
963 > * no chat has any turns.
964 > */
965 > private _latestTurnIdAcrossChats(session: ProtocolURI): string | undefined {
966 > const sessionState = this._stateManager.getSessionState(session); agentHostChangesetService.ts ×25
967 > if (!sessionState) {
968 return undefined;
969 }
971 > const chats = sessionState.chats ?? [];
972 > if (chats.length <= 1) {
973 > return sessionState.turns.at(-1)?.id;
974 > }
975
976 let bestTurnId: string | undefined;
977 let bestModifiedAt = '';
978 for (const chat of chats) {
979 const turns = isDefaultChatUri(chat.resource)
980 ? sessionState.turns
981 : this._stateManager.getChatState(chat.resource)?.turns;
982 const lastTurnId = turns?.at(-1)?.id;
983 if (lastTurnId && chat.modifiedAt >= bestModifiedAt) {
984 bestModifiedAt = chat.modifiedAt;
985 bestTurnId = lastTurnId;
986 }
987 }
988 return bestTurnId;
991 > /**
992 > * Computes diffs for a static changeset by shelling out to git.
993 > * Returns the diff list when the session has a working directory and
994 > * that directory is a git work tree; returns `undefined` otherwise so
995 > * the caller can fall back to the edit-tracker aggregator (for
996 > * `kind: 'session'`) or preserve cached state (for `kind: 'branch'`).
997 > *
998 > * For `kind: 'session'` the diff is computed between the baseline
999 > * checkpoint ref and the latest turn checkpoint ref.
1000 > * For `kind: 'branch'` the diff is computed against the merge-base
1001 > * with {@link META_DIFF_BASE_BRANCH} when one is set; without a base
1002 > * branch git falls back to `HEAD`.
1003 > */
1004 > private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase, kind: StaticChangesetKind): Promise<readonly ISessionFileDiff[] | undefined> {
1005 > const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0]; agentHostChangesetService.ts ×25
1006 > if (!workingDirectory) {
1007 return undefined;
1008 }
1010 > let workingDirectoryUri: URI;
1011 > try {
1012 > workingDirectoryUri = URI.parse(workingDirectory);
1013 > } catch {
1014 return undefined;
1015 }
1017 > // Session
1018 > if (kind === 'session') {
1019 > // Get session checkpoints. In multi-chat sessions the working tree
1020 > // is shared and each chat's turn checkpoints are stored under the
1021 > // session URI keyed by their turn id, so the most-recently-modified
1022 > // chat's last turn captures the full working-tree delta.
1023 > const latestTurnId = this._latestTurnIdAcrossChats(session);
1024 > if (!latestTurnId) {
1025 > return undefined;
1026 > }
1027
1028 const sessionUri = URI.parse(session);
1029 const [baseline, pair] = await Promise.all([
1030 this._checkpointService.getBaselineCheckpointRef(sessionUri),
1031 this._checkpointService.getTurnCheckpointPair(sessionUri, latestTurnId),
1032 ]);
1033 > if (!baseline || !pair) { agentHostChangesetService.ts ×25
1034 return undefined;
1035 }
1036
1037 try {
1038 return await this._gitService.computeFileDiffsBetweenRefs(workingDirectoryUri, {
1039 sessionUri: session,
1040 fromRef: baseline,
1041 toRef: pair.current
1042 });
1043 } catch (err) {
1044 this._logService.warn(`[AgentHostChangesetService] git-driven ${kind} diff computation failed; falling back to edit-tracker`, err);
1045 return undefined;
1046 }
1048 >
1049 > // Branch
1050 > const baseBranch = await this._resolveBranchBaseBranch(session, db);
1051 >
1052 > try {
1053 > return await this._gitService.computeSessionFileDiffs(workingDirectoryUri, {
1054 > sessionUri: session,
1055 > baseBranch
1056 > });
1057 > } catch (err) {
1058 this._logService.warn(`[AgentHostChangesetService] git-driven ${kind} diff computation failed; falling back to edit-tracker`, err);
1059 return undefined;
1060 }
1063 > /**
1064 > * Resolves the Branch Changes base branch, reused by the diff computation
1065 > * and the review-status lookup so both are keyed on the same baseline.
1066 > */
1067 > private async _resolveBranchBaseBranch(session: ProtocolURI, db: ISessionDatabase): Promise<string | undefined> {
1068 > const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH); agentHostChangesetService.ts ×25
1069 > const gitStateBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName;
1070 > if (!persistedBaseBranch && gitStateBaseBranch) {
1071 this._logService.debug(`[AgentHostChangesetService] Using _meta.git base branch fallback for Branch Changes in ${session}: ${gitStateBaseBranch}`);
1072 }
1073 > return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch); agentHostChangesetService.ts ×25
1074 > }
1076 > /**
1077 > * Computes the reviewed-paths overlay for the Branch changeset: the
1078 > * repository root (used to key file ids to repo-relative paths) and the set
1079 > * of reviewed repo-relative paths. Returns `undefined` when the session has
1080 > * no git working directory (review status is then simply omitted).
1081 > */
1082 > private async _computeReviewedInfo(session: ProtocolURI, db: ISessionDatabase): Promise<{ readonly repoRoot: URI; readonly paths: ReadonlySet<string> } | undefined> {
1083 const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
1084 if (!workingDirectory) {
1085 return undefined;
1086 }
1087
1088 let workingDirectoryUri: URI;
1089 try {
1090 workingDirectoryUri = URI.parse(workingDirectory);
1091 } catch {
1092 return undefined;
1093 }
1094
1095 const repoRoot = await this._gitService.getRepositoryRoot(workingDirectoryUri);
1096 if (!repoRoot) {
1097 return undefined;
1098 }
1099
1100 const baseBranch = await this._resolveBranchBaseBranch(session, db);
1101 const paths = await this._reviewService.getReviewedPaths(session, workingDirectoryUri, baseBranch);
1102
1103 return { repoRoot, paths };
1104 }
1106 > /**
1107 > * Persists a session metadata key/value pair to the session database.
1108 > * Counterpart in `agentSideEffects.ts` (`AgentSideEffects._persistSessionFlag`):
1109 > * keep both copies in sync if the signature changes. Duplicated rather
1110 > * than lifted because the two consumers persist disjoint metadata
1111 > * (changeset diffs here vs. customTitle / isRead / isArchived /
1112 > * configValues there) and a shared util would only have two callers.
1113 > */
1114 > private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void {
1115 > const ref = this._sessionDataService.openDatabase(URI.parse(session)); agentHostChangesetService.ts ×25
1116 > ref.object.setMetadata(key, value).catch(err => {
1117 this._logService.warn(`[AgentHostChangesetService] Failed to persist ${key}`, err);
1118 > }).finally(() => { agentHostChangesetService.ts ×25
1119 > ref.dispose();
1120 > });
1121 > }