Skip to content

Commit c70c886

Browse files
authored
Add agent session project metadata (#309114)
* Add agent session project metadata (Written by Copilot) * Persist agent session project resolution (Written by Copilot) * sync ahp
1 parent d0d0437 commit c70c886

25 files changed

+610
-70
lines changed

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export interface IAgentSessionMetadata {
4141
readonly session: URI;
4242
readonly startTime: number;
4343
readonly modifiedTime: number;
44+
readonly project?: IAgentSessionProjectInfo;
4445
readonly summary?: string;
4546
readonly status?: SessionStatus;
4647
readonly workingDirectory?: URI;
@@ -49,6 +50,16 @@ export interface IAgentSessionMetadata {
4950
readonly diffs?: readonly { readonly uri: string; readonly added?: number; readonly removed?: number }[];
5051
}
5152

53+
export interface IAgentSessionProjectInfo {
54+
readonly uri: URI;
55+
readonly displayName: string;
56+
}
57+
58+
export interface IAgentCreateSessionResult {
59+
readonly session: URI;
60+
readonly project?: IAgentSessionProjectInfo;
61+
}
62+
5263
export type AgentProvider = string;
5364

5465
/** Metadata describing an agent backend, discovered over IPC. */
@@ -321,8 +332,8 @@ export interface IAgent {
321332
/** Fires when the provider streams progress for a session. */
322333
readonly onDidSessionProgress: Event<IAgentProgressEvent>;
323334

324-
/** Create a new session. Returns the session URI. */
325-
createSession(config?: IAgentCreateSessionConfig): Promise<URI>;
335+
/** Create a new session. Returns server-owned session metadata. */
336+
createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult>;
326337

327338
/** Send a user message into an existing session. */
328339
sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise<void>;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
4e0303d
1+
1f72258

src/vs/platform/agentHost/common/state/protocol/state.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,18 @@ export interface ISessionFileDiff {
336336
removed?: number;
337337
}
338338

339+
/**
340+
* Server-owned project metadata for a session.
341+
*
342+
* @category Session State
343+
*/
344+
export interface IProjectInfo {
345+
/** Project URI */
346+
uri: URI;
347+
/** Human-readable project name */
348+
displayName: string;
349+
}
350+
339351
/**
340352
* @category Session State
341353
*/
@@ -352,6 +364,8 @@ export interface ISessionSummary {
352364
createdAt: number;
353365
/** Last modification timestamp */
354366
modifiedAt: number;
367+
/** Server-owned project for this session */
368+
project?: IProjectInfo;
355369
/** Currently selected model */
356370
model?: string;
357371
/** The working directory URI for this session */

src/vs/platform/agentHost/common/state/sessionState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export {
3636
type IAgentInfo,
3737
type IContentRef,
3838
type IErrorInfo,
39+
type IProjectInfo,
3940
type IMarkdownResponsePart,
4041
type IMessageAttachment,
4142
type IReasoningResponsePart,

src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { DeferredPromise } from '../../../base/common/async.js';
1111
import { Emitter } from '../../../base/common/event.js';
1212
import { Disposable, IReference } from '../../../base/common/lifecycle.js';
13+
import { Schemas } from '../../../base/common/network.js';
1314
import { hasKey } from '../../../base/common/types.js';
1415
import { URI } from '../../../base/common/uri.js';
1516
import { generateUuid } from '../../../base/common/uuid.js';
@@ -235,6 +236,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
235236
session: URI.parse(s.resource),
236237
startTime: s.createdAt,
237238
modifiedTime: s.modifiedAt,
239+
...(s.project ? {
240+
project: {
241+
uri: this._toLocalProjectUri(URI.parse(s.project.uri)),
242+
displayName: s.project.displayName,
243+
}
244+
} : {}),
238245
summary: s.title,
239246
status: s.status,
240247
workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined,
@@ -243,6 +250,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC
243250
}));
244251
}
245252

253+
private _toLocalProjectUri(uri: URI): URI {
254+
return uri.scheme === Schemas.file ? toAgentHostUri(uri, this._connectionAuthority) : uri;
255+
}
256+
246257
/**
247258
* List the contents of a directory on the remote host's filesystem.
248259
*/

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ export class AgentHostStateManager extends Disposable {
301301
if (current.title !== lastNotified.title) { changes.title = current.title; }
302302
if (current.status !== lastNotified.status) { changes.status = current.status; }
303303
if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; }
304+
if (current.project !== lastNotified.project) { changes.project = current.project; }
304305
if (current.model !== lastNotified.model) { changes.model = current.model; }
305306
if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; }
306307
if (current.isRead !== lastNotified.isRead) { changes.isRead = current.isRead; }

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,11 @@ export class AgentService extends Disposable implements IAgentService {
201201
// Safe to run in parallel with createSession since no events flow until
202202
// sendMessage() is called.
203203
this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`);
204-
const [, session] = await Promise.all([
204+
const [, created] = await Promise.all([
205205
this._sideEffects.initialize(),
206206
provider.createSession(config),
207207
]);
208+
const session = created.session;
208209
this._logService.trace(`[AgentService] createSession: initialization complete`);
209210

210211
this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model ?? '(default)'}`);
@@ -228,6 +229,7 @@ export class AgentService extends Disposable implements IAgentService {
228229
status: SessionStatus.Idle,
229230
createdAt: Date.now(),
230231
modifiedAt: Date.now(),
232+
...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),
231233
workingDirectory: config.workingDirectory?.toString(),
232234
};
233235
const state = this._stateManager.createSession(summary);
@@ -241,6 +243,7 @@ export class AgentService extends Disposable implements IAgentService {
241243
status: SessionStatus.Idle,
242244
createdAt: Date.now(),
243245
modifiedAt: Date.now(),
246+
...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),
244247
workingDirectory: config?.workingDirectory?.toString(),
245248
};
246249
this._stateManager.createSession(summary);
@@ -419,6 +422,7 @@ export class AgentService extends Disposable implements IAgentService {
419422
status: SessionStatus.Idle,
420423
createdAt: meta.startTime,
421424
modifiedAt: meta.modifiedTime,
425+
...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}),
422426
workingDirectory: meta.workingDirectory?.toString(),
423427
isRead,
424428
isDone,
@@ -878,6 +882,7 @@ export class AgentService extends Disposable implements IAgentService {
878882
status: SessionStatus.Idle,
879883
createdAt: Date.now(),
880884
modifiedAt: Date.now(),
885+
...(parentState?.summary.project ? { project: parentState.summary.project } : {}),
881886
},
882887
childTurns,
883888
);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ export class AgentSideEffects extends Disposable {
355355
}
356356

357357
this._logService.info(`[AgentSideEffects] Creating subagent session: ${subagentSessionUri} (parent=${parentSession}, toolCallId=${toolCallId})`);
358+
const parentState = this._stateManager.getSessionState(parentSession);
358359

359360
// Create the subagent session silently (restoreSession skips notification)
360361
this._stateManager.restoreSession(
@@ -365,6 +366,7 @@ export class AgentSideEffects extends Disposable {
365366
status: SessionStatus.Idle,
366367
createdAt: Date.now(),
367368
modifiedAt: Date.now(),
369+
...(parentState?.summary.project ? { project: parentState.summary.project } : {}),
368370
},
369371
[],
370372
);

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

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { CopilotClient } from '@github/copilot-sdk';
77
import { rgPath } from '@vscode/ripgrep';
8-
import { SequencerByKey } from '../../../../base/common/async.js';
8+
import { Limiter, SequencerByKey } from '../../../../base/common/async.js';
99
import { Emitter } from '../../../../base/common/event.js';
1010
import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';
1111
import { FileAccess } from '../../../../base/common/network.js';
@@ -17,7 +17,7 @@ import { IFileService } from '../../../files/common/files.js';
1717
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
1818
import { ILogService } from '../../../log/common/log.js';
1919
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
20-
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
20+
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
2121
import { ISessionDataService } from '../../common/sessionDataService.js';
2222
import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js';
2323
import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js';
@@ -26,6 +26,7 @@ import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
2626
import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js';
2727
import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js';
2828
import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js';
29+
import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js';
2930
import { createShellTools, ShellManager } from './copilotShellTools.js';
3031

3132
/**
@@ -161,12 +162,23 @@ export class CopilotAgent extends Disposable implements IAgent {
161162
this._logService.info('[Copilot] Listing sessions...');
162163
const client = await this._ensureClient();
163164
const sessions = await client.listSessions();
164-
const result: IAgentSessionMetadata[] = sessions.map(s => ({
165-
session: AgentSession.uri(this.id, s.sessionId),
166-
startTime: s.startTime.getTime(),
167-
modifiedTime: s.modifiedTime.getTime(),
168-
summary: s.summary,
169-
workingDirectory: typeof s.context?.cwd === 'string' ? URI.file(s.context.cwd) : undefined,
165+
const projectLimiter = new Limiter<IAgentSessionProjectInfo | undefined>(4);
166+
const projectByContext = new Map<string, Promise<IAgentSessionProjectInfo | undefined>>();
167+
const result: IAgentSessionMetadata[] = await Promise.all(sessions.map(async s => {
168+
const session = AgentSession.uri(this.id, s.sessionId);
169+
let { project, resolved } = await this._readSessionProject(session);
170+
if (!resolved) {
171+
project = await this._resolveSessionProject(s.context, projectLimiter, projectByContext);
172+
this._storeSessionProjectResolution(session, project);
173+
}
174+
return {
175+
session,
176+
startTime: s.startTime.getTime(),
177+
modifiedTime: s.modifiedTime.getTime(),
178+
...(project ? { project } : {}),
179+
summary: s.summary,
180+
workingDirectory: typeof s.context?.cwd === 'string' ? URI.file(s.context.cwd) : undefined,
181+
};
170182
}));
171183
this._logService.info(`[Copilot] Found ${result.length} sessions`);
172184
return result;
@@ -192,7 +204,7 @@ export class CopilotAgent extends Disposable implements IAgent {
192204
return result;
193205
}
194206

195-
async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
207+
async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
196208
this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model}` : ''}`);
197209
const client = await this._ensureClient();
198210
const parsedPlugins = await this._plugins.getAppliedPlugins();
@@ -220,7 +232,9 @@ export class CopilotAgent extends Disposable implements IAgent {
220232
const agentSession = await this._resumeSession(newSessionId);
221233
const session = agentSession.sessionUri;
222234
this._logService.info(`[Copilot] Forked session created: ${session.toString()}`);
223-
return session;
235+
const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath });
236+
this._storeSessionMetadata(session, undefined, config.workingDirectory, project, true);
237+
return { session, ...(project ? { project } : {}) };
224238
});
225239
}
226240

@@ -244,13 +258,13 @@ export class CopilotAgent extends Disposable implements IAgent {
244258
this._plugins.setAppliedPlugins(agentSession, parsedPlugins);
245259
await agentSession.initializeSession();
246260

247-
// Persist model & working directory so we can recreate the session
248-
// if the SDK loses it (e.g. sessions without messages).
249-
this._storeSessionMetadata(agentSession.sessionUri, config?.model, config?.workingDirectory);
250-
251261
const session = agentSession.sessionUri;
252262
this._logService.info(`[Copilot] Session created: ${session.toString()}`);
253-
return session;
263+
const project = await projectFromCopilotContext({ cwd: config?.workingDirectory?.fsPath });
264+
// Persist model, working directory, and project so we can recreate the
265+
// session if the SDK loses it and avoid rediscovering git metadata.
266+
this._storeSessionMetadata(agentSession.sessionUri, config?.model, config?.workingDirectory, project, true);
267+
return { session, ...(project ? { project } : {}) };
254268
}
255269

256270
async setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise<ISyncedCustomization[]> {
@@ -365,7 +379,7 @@ export class CopilotAgent extends Disposable implements IAgent {
365379
if (entry) {
366380
await entry.setModel(model);
367381
}
368-
this._storeSessionMetadata(session, model, undefined);
382+
this._storeSessionMetadata(session, model, undefined, undefined);
369383
}
370384

371385
async shutdown(): Promise<void> {
@@ -496,8 +510,11 @@ export class CopilotAgent extends Disposable implements IAgent {
496510

497511
private static readonly _META_MODEL = 'copilot.model';
498512
private static readonly _META_CWD = 'copilot.workingDirectory';
513+
private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved';
514+
private static readonly _META_PROJECT_URI = 'copilot.project.uri';
515+
private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName';
499516

500-
private _storeSessionMetadata(session: URI, model: string | undefined, workingDirectory: URI | undefined): void {
517+
private _storeSessionMetadata(session: URI, model: string | undefined, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): void {
501518
const dbRef = this._sessionDataService.tryOpenDatabase(session);
502519
dbRef?.then(ref => {
503520
if (!ref) {
@@ -511,6 +528,13 @@ export class CopilotAgent extends Disposable implements IAgent {
511528
if (workingDirectory) {
512529
work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString()));
513530
}
531+
if (projectResolved) {
532+
work.push(db.setMetadata(CopilotAgent._META_PROJECT_RESOLVED, 'true'));
533+
}
534+
if (project) {
535+
work.push(db.setMetadata(CopilotAgent._META_PROJECT_URI, project.uri.toString()));
536+
work.push(db.setMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME, project.displayName));
537+
}
514538
Promise.all(work).finally(() => ref.dispose());
515539
});
516540
}
@@ -534,6 +558,55 @@ export class CopilotAgent extends Disposable implements IAgent {
534558
}
535559
}
536560

561+
private async _readSessionProject(session: URI): Promise<{ project?: IAgentSessionProjectInfo; resolved: boolean }> {
562+
const ref = await this._sessionDataService.tryOpenDatabase(session);
563+
if (!ref) {
564+
return { resolved: false };
565+
}
566+
try {
567+
const [resolved, uri, displayName] = await Promise.all([
568+
ref.object.getMetadata(CopilotAgent._META_PROJECT_RESOLVED),
569+
ref.object.getMetadata(CopilotAgent._META_PROJECT_URI),
570+
ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME),
571+
]);
572+
const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined;
573+
return { project, resolved: resolved === 'true' || project !== undefined };
574+
} finally {
575+
ref.dispose();
576+
}
577+
}
578+
579+
private _storeSessionProjectResolution(session: URI, project: IAgentSessionProjectInfo | undefined): void {
580+
this._storeSessionMetadata(session, undefined, undefined, project, true);
581+
}
582+
583+
private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter<IAgentSessionProjectInfo | undefined>, projectByContext: Map<string, Promise<IAgentSessionProjectInfo | undefined>>): Promise<IAgentSessionProjectInfo | undefined> {
584+
const key = this._projectContextKey(context);
585+
if (!key) {
586+
return Promise.resolve(undefined);
587+
}
588+
589+
let project = projectByContext.get(key);
590+
if (!project) {
591+
project = limiter.queue(() => projectFromCopilotContext(context));
592+
projectByContext.set(key, project);
593+
}
594+
return project;
595+
}
596+
597+
private _projectContextKey(context: ICopilotSessionContext | undefined): string | undefined {
598+
if (context?.cwd) {
599+
return `cwd:${context.cwd}`;
600+
}
601+
if (context?.gitRoot) {
602+
return `gitRoot:${context.gitRoot}`;
603+
}
604+
if (context?.repository) {
605+
return `repository:${context.repository}`;
606+
}
607+
return undefined;
608+
}
609+
537610
override dispose(): void {
538611
this._client?.stop().catch(() => { /* best-effort */ });
539612
super.dispose();

0 commit comments

Comments
 (0)