Skip to content

Commit 8ae0d8e

Browse files
roblourensCopilot
andauthored
agent-host: report git-driven session file diffs (#312632)
* agent-host: report git-driven session file diffs (Written by Copilot) Adds an alternative diff source for agent sessions that derives changes from the working-tree git state instead of from edit-tool emissions, so edits made via terminal/shell commands also show up in the Changes view. Approach -------- * IAgentHostGitService gains computeSessionFileDiffs() and showBlob(). * gitDiffContent.ts encodes 'git-blob:' content URIs that pin a blob to a session + sha + repo-relative path. * AgentService routes 'git-blob:' resourceRead requests to gitService.showBlob(); AgentHostFileSystemProvider.stat() short- circuits 'git-blob:' alongside 'session-db:' so the diff editor's stat-then-read flow works end-to-end. * AgentSideEffects._tryComputeGitDiffs runs after each turn (debounced with the existing diff scheduler) and overrides edit-tool diffs when git is available. Tests ----- Unit + integration coverage in src/vs/platform/agentHost/test/node/. * agent-host: regression tests for git-blob: stat fast-path Adds five tests against AgentHostFileSystemProvider covering the synthetic content scheme fast-path in stat(): git-blob: and session-db: URIs must return a File stat directly without trying to list a parent directory that doesn't exist. Verified by reverting the git-blob: branch of the theallowlist new 'git-blob: stat' and 'full stat-then-read round-trip' tests fail with EntryNotFound, which is exactly the error the diff editor surfaced when opening a diff of a new file. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent-host: address Copilot review feedback - agentHostGitService: bump default execFile maxBuffer to 32MB so diff output in large repos doesn't fail with ENOBUFS and silently drop terminal-driven diffs. - agentHostGitService.showBlob: validate sha is a hex object name before passing it to git, so a malformed git-blob: URI can't inject options or resolve to surprising refs. - mockAgent terminal-edit branch: void+catch the async IIFE so a filesystem failure surfaces as a chat delta instead of an unhandled rejection (test flake source). - agentSideEffects.test: replace setTimeout(100) with awaiting the SessionDiffsChanged envelope event for both new diff-computation deterministic and immune to slow CI.tests - sessionDiffsRealSdk integrationTest: shell-quote the target file path so temp dirs containing spaces don't break the prompt. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent-host: use IFileService and INativeEnvironmentService in AgentHostGitService Replace direct fs/promises and os.tmpdir usage with platform services so the temp-index dance in computeSessionFileDiffs goes through the same file system abstraction as the rest of the workbench. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent-host: set COMMAND_HOOK_LOCK=1 in temp-index env for GVFS repos Mirrors the extension's buildTempIndexEnv which sets this flag to avoid holding the GVFS command hook lock during temp-index git operations. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agent-host: inject IAgentHostGitService via DI into AgentSideEffects Instead of threading gitService through IAgentSideEffectsOptions, register it in the local ServiceCollection and inject it via @IAgentHostGitService decorator, which is the normal pattern everywhere else. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix deleted file diffs: use IChatSessionFileChange2 with undefined modifiedUri for deletions For deleted files, the 'modified' side of the diff editor must be absent. The renderer detects deletions via `change.modifiedUri === undefined`, so producing `IChatSessionFileChange2` (which carries a `uri` key alongside optional `modifiedUri`) is the right shape. Previously diffsToChanges returned IChatSessionFileChange (required modifiedUri) and fell back to the deleted file's pre-deletion path as modifiedUri, causing the diff editor to try to read a nonexistent file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5d9b7ef commit 8ae0d8e

22 files changed

Lines changed: 1334 additions & 51 deletions

src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS
9191
return { type: FileType.Directory, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly };
9292
}
9393
const decoded = this._decodeUri(resource);
94-
if (decoded.scheme === 'session-db') {
94+
if (decoded.scheme === 'session-db' || decoded.scheme === 'git-blob') {
9595
return { type: FileType.File, mtime: 0, ctime: 0, size: 0, permissions: FilePermission.Readonly };
9696
}
9797

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

Lines changed: 308 additions & 3 deletions
Large diffs are not rendered by default.

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,21 +89,23 @@ function startAgentHost(): void {
8989
// Create the real service implementation that lives in this process
9090
let agentService: AgentService;
9191
try {
92-
const gitService = new AgentHostGitService();
93-
agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService);
94-
const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService);
92+
// Build the DI container early so the git service can be created via
93+
// `createInstance` (it needs IFileService + INativeEnvironmentService).
9594
const diServices = new ServiceCollection();
9695
diServices.set(INativeEnvironmentService, environmentService);
9796
diServices.set(ILogService, logService);
9897
diServices.set(IFileService, fileService);
9998
diServices.set(ISessionDataService, sessionDataService);
99+
const instantiationService = new InstantiationService(diServices);
100+
const gitService = instantiationService.createInstance(AgentHostGitService);
101+
diServices.set(IAgentHostGitService, gitService);
102+
agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService);
103+
const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService);
100104
diServices.set(IAgentPluginManager, pluginManager);
101105
const diffComputeService = disposables.add(new NodeWorkerDiffComputeService(logService));
102106
diServices.set(IDiffComputeService, diffComputeService);
103107

104108
diServices.set(IAgentHostTerminalManager, agentService.terminalManager);
105-
const instantiationService = new InstantiationService(diServices);
106-
diServices.set(IAgentHostGitService, gitService);
107109
agentService.registerProvider(instantiationService.createInstance(CopilotAgent));
108110
} catch (err) {
109111
logService.error('Failed to create AgentService', err);

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

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,26 +162,31 @@ async function main(): Promise<void> {
162162
// Session data service
163163
const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService);
164164

165+
// Build the DI container early so the git service can be created via
166+
// `createInstance` (it needs IFileService + INativeEnvironmentService).
167+
// The git service is shared by AgentService (for diff computation +
168+
// showBlob) and the production agent registration path.
169+
const diServices = new ServiceCollection();
170+
diServices.set(IProductService, productService);
171+
diServices.set(INativeEnvironmentService, environmentService);
172+
diServices.set(ILogService, logService);
173+
diServices.set(IFileService, fileService);
174+
diServices.set(ISessionDataService, sessionDataService);
175+
const instantiationService = new InstantiationService(diServices);
176+
const gitService = instantiationService.createInstance(AgentHostGitService);
177+
165178
// Create the agent service (owns AgentHostStateManager + AgentSideEffects internally)
166-
const gitService = new AgentHostGitService();
167179
const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService);
168180
disposables.add(agentService);
169181

170182
// Register agents
171183
if (!options.quiet) {
172184
// Production agents (require DI)
173-
const diServices = new ServiceCollection();
174185
const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService);
175-
diServices.set(IProductService, productService);
176-
diServices.set(INativeEnvironmentService, environmentService);
177-
diServices.set(ILogService, logService);
178-
diServices.set(IFileService, fileService);
179-
diServices.set(ISessionDataService, sessionDataService);
180186
diServices.set(IAgentPluginManager, pluginManager);
181187
diServices.set(IDiffComputeService, disposables.add(new NodeWorkerDiffComputeService(logService)));
182188
diServices.set(IAgentHostTerminalManager, agentService.terminalManager);
183189
diServices.set(IAgentHostGitService, gitService);
184-
const instantiationService = new InstantiationService(diServices);
185190
const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent));
186191
agentService.registerProvider(copilotAgent);
187192
log('CopilotAgent registered');

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { AgentConfigurationService, IAgentConfigurationService } from './agentCo
2626
import { AgentSideEffects } from './agentSideEffects.js';
2727
import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js';
2828
import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js';
29+
import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js';
2930
import { AgentHostStateManager } from './agentHostStateManager.js';
3031
import { IAgentHostGitService } from './agentHostGitService.js';
3132

@@ -105,6 +106,7 @@ export class AgentService extends Disposable implements IAgentService {
105106
const services = new ServiceCollection(
106107
[ILogService, this._logService],
107108
[IAgentConfigurationService, configurationService],
109+
[IAgentHostGitService, this._gitService],
108110
);
109111
const instantiationService = this._register(new InstantiationService(services, /*strict*/ true));
110112

@@ -648,6 +650,15 @@ export class AgentService extends Disposable implements IAgentService {
648650
return this._fetchSessionDbContent(dbFields);
649651
}
650652

653+
// Handle git-blob: URIs that reference file content at a specific
654+
// git commit (the merge-base used as diff baseline). The URI
655+
// encodes the session it belongs to so we can find the right
656+
// working directory to run `git show` from.
657+
const blobFields = parseGitBlobUri(uri.toString());
658+
if (blobFields) {
659+
return this._fetchGitBlobContent(blobFields);
660+
}
661+
651662
try {
652663
const content = await this._fileService.readFile(uri);
653664
return {
@@ -1031,6 +1042,25 @@ export class AgentService extends Disposable implements IAgentService {
10311042
}
10321043
}
10331044

1045+
private async _fetchGitBlobContent(fields: IGitBlobUriFields): Promise<ResourceReadResult> {
1046+
if (!this._gitService) {
1047+
throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`);
1048+
}
1049+
const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.summary.workingDirectory;
1050+
if (!workingDirectory) {
1051+
throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`);
1052+
}
1053+
const blob = await this._gitService.showBlob(URI.parse(workingDirectory), fields.sha, fields.repoRelativePath);
1054+
if (!blob) {
1055+
throw new ProtocolError(AhpErrorCodes.NotFound, `git blob not found: ${fields.sha}:${fields.repoRelativePath}`);
1056+
}
1057+
return {
1058+
data: blob.toString(),
1059+
encoding: ContentEncoding.Utf8,
1060+
contentType: 'text/plain',
1061+
};
1062+
}
1063+
10341064
/**
10351065
* Restores a subagent session from its parent session's event history.
10361066
* Loads the parent's raw messages, filters for events belonging to

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

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { ILogService } from '../../log/common/log.js';
1414
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
1515
import { IAgent, IAgentAttachment, IAgentProgressEvent, type IAgentToolCompleteEvent, type IAgentToolReadyEvent } from '../common/agentService.js';
1616
import { IDiffComputeService } from '../common/diffComputeService.js';
17-
import { ISessionDataService } from '../common/sessionDataService.js';
17+
import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js';
1818
import type { AgentInfo } from '../common/state/protocol/state.js';
1919
import { ActionType, SessionAction } from '../common/state/sessionActions.js';
2020
import {
@@ -29,10 +29,12 @@ import {
2929
type SessionCustomization,
3030
type SessionState,
3131
type ToolResultContent,
32+
type ISessionFileDiff,
3233
type URI as ProtocolURI,
3334
} from '../common/state/sessionState.js';
3435
import { AgentEventMapper } from './agentEventMapper.js';
3536
import { AgentHostStateManager } from './agentHostStateManager.js';
37+
import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from './agentHostGitService.js';
3638
import { NodeWorkerDiffComputeService } from './diffComputeService.js';
3739
import { computeSessionDiffs, type IIncrementalDiffOptions } from './sessionDiffAggregator.js';
3840
import { SessionPermissionManager } from './sessionPermissions.js';
@@ -113,6 +115,7 @@ export class AgentSideEffects extends Disposable {
113115
private readonly _options: IAgentSideEffectsOptions,
114116
@IInstantiationService instantiationService: IInstantiationService,
115117
@ILogService private readonly _logService: ILogService,
118+
@IAgentHostGitService private readonly _gitService: IAgentHostGitService,
116119
) {
117120
super();
118121
this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService));
@@ -931,20 +934,29 @@ export class AgentSideEffects extends Disposable {
931934
return;
932935
}
933936
try {
934-
// Build incremental options when a specific turn triggered the recomputation
935-
let incremental: IIncrementalDiffOptions | undefined;
936-
if (changedTurnId) {
937-
const previousDiffs = this._stateManager.getSessionState(session)?.summary.diffs;
938-
if (previousDiffs) {
939-
incremental = { changedTurnId, previousDiffs };
937+
// Prefer a git-driven diff so terminal-driven file changes show up
938+
// alongside SDK-tracked tool edits. The git path is the source of
939+
// truth whenever the working directory is a real work tree; we
940+
// only fall back to the edit-tracker aggregator when it isn't
941+
// (e.g. agents running in non-git scratch directories or under
942+
// test harnesses without git).
943+
let diffs = await this._tryComputeGitDiffs(session, ref.object);
944+
if (!diffs) {
945+
// Build incremental options when a specific turn triggered the recomputation
946+
let incremental: IIncrementalDiffOptions | undefined;
947+
if (changedTurnId) {
948+
const previousDiffs = this._stateManager.getSessionState(session)?.summary.diffs;
949+
if (previousDiffs) {
950+
incremental = { changedTurnId, previousDiffs };
951+
}
940952
}
953+
diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental);
941954
}
942955

943-
const diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental);
944956
this._stateManager.dispatchServerAction({
945957
type: ActionType.SessionDiffsChanged,
946958
session,
947-
diffs,
959+
diffs: [...diffs],
948960
});
949961
// Persist diffs to the session database so they survive restarts
950962
ref.object.setMetadata('diffs', JSON.stringify(diffs)).catch(err => {
@@ -957,6 +969,35 @@ export class AgentSideEffects extends Disposable {
957969
}
958970
}
959971

972+
/**
973+
* Computes session diffs by shelling out to git. Returns the diff list
974+
* when the session has a working directory and that directory is a git
975+
* work tree; returns `undefined` otherwise so the caller can fall back
976+
* to the edit-tracker aggregator. The base branch (anchor for the
977+
* `merge-base` baseline) is read from the provider-agnostic
978+
* {@link META_DIFF_BASE_BRANCH} metadata key — agents that create
979+
* worktrees write it at session-creation time.
980+
*/
981+
private async _tryComputeGitDiffs(session: ProtocolURI, db: ISessionDatabase): Promise<readonly ISessionFileDiff[] | undefined> {
982+
const workingDirectory = this._stateManager.getSessionState(session)?.summary.workingDirectory;
983+
if (!workingDirectory) {
984+
return undefined;
985+
}
986+
let workingDirectoryUri: URI;
987+
try {
988+
workingDirectoryUri = URI.parse(workingDirectory);
989+
} catch {
990+
return undefined;
991+
}
992+
const baseBranch = (await db.getMetadata(META_DIFF_BASE_BRANCH)) ?? undefined;
993+
try {
994+
return await this._gitService.computeSessionFileDiffs(workingDirectoryUri, { sessionUri: session, baseBranch });
995+
} catch (err) {
996+
this._logService.warn('[AgentSideEffects] git-driven diff computation failed; falling back to edit-tracker', err);
997+
return undefined;
998+
}
999+
}
1000+
9601001
override dispose(): void {
9611002
this._toolCallAgents.clear();
9621003
super.dispose();

src/vs/platform/agentHost/node/copilot/copilotAgent.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from
3131
import { ProtectedResourceMetadata, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
3232
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js';
3333
import { CustomizationStatus, CustomizationRef, SessionInputResponseKind, type PendingMessage, type SessionInputAnswer, type ToolCallResult, type PolicyState } from '../../common/state/sessionState.js';
34-
import { IAgentHostGitService } from '../agentHostGitService.js';
34+
import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../agentHostGitService.js';
3535
import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
3636
import { CopilotAgentSession, SessionWrapperFactory, type IActiveClientSnapshot } from './copilotAgentSession.js';
3737
import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js';
@@ -1024,7 +1024,7 @@ export class CopilotAgent extends Disposable implements IAgent {
10241024
this._pendingFirstTurnAnnouncements.set(sessionId, buildWorktreeAnnouncementText(branchName));
10251025
const sessionUri = AgentSession.uri(this.id, sessionId);
10261026
try {
1027-
await this._writeWorktreeBranchMetadata(sessionUri, branchName);
1027+
await this._writeWorktreeBranchMetadata(sessionUri, branchName, baseBranch);
10281028
} catch (error) {
10291029
this._logService.warn(`[Copilot:${sessionId}] Failed to persist worktree branch metadata: ${error instanceof Error ? error.message : String(error)}`);
10301030
}
@@ -1054,10 +1054,14 @@ export class CopilotAgent extends Disposable implements IAgent {
10541054
private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName';
10551055
private static readonly _META_WORKTREE_BRANCH = 'copilot.worktree.branchName';
10561056

1057-
private async _writeWorktreeBranchMetadata(session: URI, branchName: string): Promise<void> {
1057+
private async _writeWorktreeBranchMetadata(session: URI, branchName: string, baseBranch: string | undefined): Promise<void> {
10581058
const dbRef = this._sessionDataService.openDatabase(session);
10591059
try {
1060-
await dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, branchName);
1060+
const work: Promise<void>[] = [dbRef.object.setMetadata(CopilotAgent._META_WORKTREE_BRANCH, branchName)];
1061+
if (baseBranch) {
1062+
work.push(dbRef.object.setMetadata(META_DIFF_BASE_BRANCH, baseBranch));
1063+
}
1064+
await Promise.all(work);
10611065
} finally {
10621066
dbRef.dispose();
10631067
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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 { decodeHex, encodeHex, VSBuffer } from '../../../base/common/buffer.js';
7+
import { basename } from '../../../base/common/path.js';
8+
import { URI } from '../../../base/common/uri.js';
9+
10+
const GIT_BLOB_SCHEME = 'git-blob';
11+
12+
/**
13+
* Builds a `git-blob:` URI that references a file blob at a specific git
14+
* commit, scoped to a given session. Resolved by reading the session's
15+
* working directory and shelling out to `git show <sha>:<path>`.
16+
*
17+
* The session URI is preserved so the resolver can find the session's
18+
* working directory; the SHA and repository-relative path identify the
19+
* blob to fetch.
20+
*/
21+
export function buildGitBlobUri(sessionUri: string, sha: string, repoRelativePath: string): string {
22+
return URI.from({
23+
scheme: GIT_BLOB_SCHEME,
24+
authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(),
25+
path: `/${encodeURIComponent(sha)}/${encodeHex(VSBuffer.fromString(repoRelativePath))}/${basename(repoRelativePath)}`,
26+
}).toString();
27+
}
28+
29+
/** Parsed fields from a `git-blob:` content URI. */
30+
export interface IGitBlobUriFields {
31+
readonly sessionUri: string;
32+
readonly sha: string;
33+
readonly repoRelativePath: string;
34+
}
35+
36+
/**
37+
* Parses a `git-blob:` URI produced by {@link buildGitBlobUri}.
38+
* Returns `undefined` if the URI is not a valid `git-blob:` URI.
39+
*/
40+
export function parseGitBlobUri(raw: string): IGitBlobUriFields | undefined {
41+
let parsed: URI;
42+
try {
43+
parsed = URI.parse(raw);
44+
} catch {
45+
return undefined;
46+
}
47+
if (parsed.scheme !== GIT_BLOB_SCHEME) {
48+
return undefined;
49+
}
50+
const [, sha, encodedPath] = parsed.path.split('/');
51+
if (!sha || !encodedPath) {
52+
return undefined;
53+
}
54+
try {
55+
return {
56+
sessionUri: decodeHex(parsed.authority).toString(),
57+
sha: decodeURIComponent(sha),
58+
repoRelativePath: decodeHex(encodedPath).toString(),
59+
};
60+
} catch {
61+
return undefined;
62+
}
63+
}

0 commit comments

Comments
 (0)