From cfe5a913817840bfc617553cc6dc78f48c17f109 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 10 Apr 2026 14:42:20 -0700 Subject: [PATCH 1/3] Add agent session project metadata (Written by Copilot) --- .../platform/agentHost/common/agentService.ts | 15 ++- .../common/state/protocol/.ahp-version | 2 +- .../agentHost/common/state/protocol/state.ts | 14 +++ .../agentHost/common/state/sessionState.ts | 1 + .../remoteAgentHostProtocolClient.ts | 11 ++ .../agentHost/node/agentHostStateManager.ts | 1 + .../platform/agentHost/node/agentService.ts | 7 +- .../agentHost/node/agentSideEffects.ts | 2 + .../agentHost/node/copilot/copilotAgent.ts | 27 ++-- .../node/copilot/copilotGitProject.ts | 87 +++++++++++++ .../agentHost/node/protocolServerHandler.ts | 1 + .../test/common/agentSubscription.test.ts | 1 + .../test/node/agentHostStateManager.test.ts | 1 + .../agentHost/test/node/agentService.test.ts | 10 +- .../test/node/agentSideEffects.test.ts | 4 +- .../test/node/copilotGitProject.test.ts | 117 ++++++++++++++++++ .../platform/agentHost/test/node/mockAgent.ts | 18 +-- .../test/node/protocolServerHandler.test.ts | 82 +++++++++++- .../agentHost/test/node/reducers.test.ts | 1 + src/vs/sessions/README.md | 6 + .../browser/localAgentHostSessionsProvider.ts | 39 +++++- .../localAgentHostSessionsProvider.test.ts | 31 ++++- .../remoteAgentHostSessionsProvider.ts | 51 ++++++-- .../remoteAgentHostSessionsProvider.test.ts | 51 +++++++- 24 files changed, 531 insertions(+), 49 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/copilotGitProject.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotGitProject.test.ts diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index dafad724ec6471..e42856039517ca 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -41,6 +41,7 @@ export interface IAgentSessionMetadata { readonly session: URI; readonly startTime: number; readonly modifiedTime: number; + readonly project?: IAgentSessionProjectInfo; readonly summary?: string; readonly status?: SessionStatus; readonly workingDirectory?: URI; @@ -49,6 +50,16 @@ export interface IAgentSessionMetadata { readonly diffs?: readonly { readonly uri: string; readonly added?: number; readonly removed?: number }[]; } +export interface IAgentSessionProjectInfo { + readonly uri: URI; + readonly displayName: string; +} + +export interface IAgentCreateSessionResult { + readonly session: URI; + readonly project?: IAgentSessionProjectInfo; +} + export type AgentProvider = string; /** Metadata describing an agent backend, discovered over IPC. */ @@ -321,8 +332,8 @@ export interface IAgent { /** Fires when the provider streams progress for a session. */ readonly onDidSessionProgress: Event; - /** Create a new session. Returns the session URI. */ - createSession(config?: IAgentCreateSessionConfig): Promise; + /** Create a new session. Returns server-owned session metadata. */ + createSession(config?: IAgentCreateSessionConfig): Promise; /** Send a user message into an existing session. */ sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[], turnId?: string): Promise; diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 19be947eaeb5a0..1b882c1574e62a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -4e0303d +8a85cec diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 9f2cbcbdb24805..d6f02af2fd4797 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -336,6 +336,18 @@ export interface ISessionFileDiff { removed?: number; } +/** + * Server-owned project metadata for a session. + * + * @category Session State + */ +export interface IProjectInfo { + /** Project URI */ + uri: URI; + /** Human-readable project name */ + displayName: string; +} + /** * @category Session State */ @@ -352,6 +364,8 @@ export interface ISessionSummary { createdAt: number; /** Last modification timestamp */ modifiedAt: number; + /** Server-owned project for this session */ + project?: IProjectInfo; /** Currently selected model */ model?: string; /** The working directory URI for this session */ diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 7bf960d5010683..676045aa02c407 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -36,6 +36,7 @@ export { type IAgentInfo, type IContentRef, type IErrorInfo, + type IProjectInfo, type IMarkdownResponsePart, type IMessageAttachment, type IReasoningResponsePart, diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index 6f439b7f022702..0b6c44e9301c96 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -10,6 +10,7 @@ import { DeferredPromise } from '../../../base/common/async.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, IReference } from '../../../base/common/lifecycle.js'; +import { Schemas } from '../../../base/common/network.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; @@ -235,6 +236,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC session: URI.parse(s.resource), startTime: s.createdAt, modifiedTime: s.modifiedAt, + ...(s.project ? { + project: { + uri: this._toLocalProjectUri(URI.parse(s.project.uri)), + displayName: s.project.displayName, + } + } : {}), summary: s.title, status: s.status, workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, @@ -243,6 +250,10 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC })); } + private _toLocalProjectUri(uri: URI): URI { + return uri.scheme === Schemas.file ? toAgentHostUri(uri, this._connectionAuthority) : uri; + } + /** * List the contents of a directory on the remote host's filesystem. */ diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index d32378a06a3a8f..255ffb6c92515b 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -301,6 +301,7 @@ export class AgentHostStateManager extends Disposable { if (current.title !== lastNotified.title) { changes.title = current.title; } if (current.status !== lastNotified.status) { changes.status = current.status; } if (current.modifiedAt !== lastNotified.modifiedAt) { changes.modifiedAt = current.modifiedAt; } + if (current.project !== lastNotified.project) { changes.project = current.project; } if (current.model !== lastNotified.model) { changes.model = current.model; } if (current.workingDirectory !== lastNotified.workingDirectory) { changes.workingDirectory = current.workingDirectory; } if (current.isRead !== lastNotified.isRead) { changes.isRead = current.isRead; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index ab1f9d9c74fa33..13561b5e3c9329 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -201,10 +201,11 @@ export class AgentService extends Disposable implements IAgentService { // Safe to run in parallel with createSession since no events flow until // sendMessage() is called. this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`); - const [, session] = await Promise.all([ + const [, created] = await Promise.all([ this._sideEffects.initialize(), provider.createSession(config), ]); + const session = created.session; this._logService.trace(`[AgentService] createSession: initialization complete`); this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model ?? '(default)'}`); @@ -228,6 +229,7 @@ export class AgentService extends Disposable implements IAgentService { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), workingDirectory: config.workingDirectory?.toString(), }; const state = this._stateManager.createSession(summary); @@ -241,6 +243,7 @@ export class AgentService extends Disposable implements IAgentService { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), workingDirectory: config?.workingDirectory?.toString(), }; this._stateManager.createSession(summary); @@ -419,6 +422,7 @@ export class AgentService extends Disposable implements IAgentService { status: SessionStatus.Idle, createdAt: meta.startTime, modifiedAt: meta.modifiedTime, + ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), workingDirectory: meta.workingDirectory?.toString(), isRead, isDone, @@ -878,6 +882,7 @@ export class AgentService extends Disposable implements IAgentService { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + ...(parentState?.summary.project ? { project: parentState.summary.project } : {}), }, childTurns, ); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 5f2301f4e8ce17..afa140018058cc 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -355,6 +355,7 @@ export class AgentSideEffects extends Disposable { } this._logService.info(`[AgentSideEffects] Creating subagent session: ${subagentSessionUri} (parent=${parentSession}, toolCallId=${toolCallId})`); + const parentState = this._stateManager.getSessionState(parentSession); // Create the subagent session silently (restoreSession skips notification) this._stateManager.restoreSession( @@ -365,6 +366,7 @@ export class AgentSideEffects extends Disposable { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + ...(parentState?.summary.project ? { project: parentState.summary.project } : {}), }, [], ); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index bcbd31a1ac972c..4b404da3633205 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -17,7 +17,7 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js'; import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js'; @@ -26,6 +26,7 @@ import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js'; import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { projectFromCopilotContext } from './copilotGitProject.js'; import { createShellTools, ShellManager } from './copilotShellTools.js'; /** @@ -161,12 +162,16 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing sessions...'); const client = await this._ensureClient(); const sessions = await client.listSessions(); - const result: IAgentSessionMetadata[] = sessions.map(s => ({ - session: AgentSession.uri(this.id, s.sessionId), - startTime: s.startTime.getTime(), - modifiedTime: s.modifiedTime.getTime(), - summary: s.summary, - workingDirectory: typeof s.context?.cwd === 'string' ? URI.file(s.context.cwd) : undefined, + const result: IAgentSessionMetadata[] = await Promise.all(sessions.map(async s => { + const project = await projectFromCopilotContext(s.context); + return { + session: AgentSession.uri(this.id, s.sessionId), + startTime: s.startTime.getTime(), + modifiedTime: s.modifiedTime.getTime(), + ...(project ? { project } : {}), + summary: s.summary, + workingDirectory: typeof s.context?.cwd === 'string' ? URI.file(s.context.cwd) : undefined, + }; })); this._logService.info(`[Copilot] Found ${result.length} sessions`); return result; @@ -192,7 +197,7 @@ export class CopilotAgent extends Disposable implements IAgent { return result; } - async createSession(config?: IAgentCreateSessionConfig): Promise { + async createSession(config?: IAgentCreateSessionConfig): Promise { this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model}` : ''}`); const client = await this._ensureClient(); const parsedPlugins = await this._plugins.getAppliedPlugins(); @@ -220,7 +225,8 @@ export class CopilotAgent extends Disposable implements IAgent { const agentSession = await this._resumeSession(newSessionId); const session = agentSession.sessionUri; this._logService.info(`[Copilot] Forked session created: ${session.toString()}`); - return session; + const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath }); + return { session, ...(project ? { project } : {}) }; }); } @@ -250,7 +256,8 @@ export class CopilotAgent extends Disposable implements IAgent { const session = agentSession.sessionUri; this._logService.info(`[Copilot] Session created: ${session.toString()}`); - return session; + const project = await projectFromCopilotContext({ cwd: config?.workingDirectory?.fsPath }); + return { session, ...(project ? { project } : {}) }; } async setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts b/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts new file mode 100644 index 00000000000000..c1e2d22f8557f7 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotGitProject.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as cp from 'child_process'; +import { Schemas } from '../../../../base/common/network.js'; +import { basename } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import type { IAgentSessionProjectInfo } from '../../common/agentService.js'; + +export interface ICopilotSessionContext { + readonly cwd?: string; + readonly gitRoot?: string; + readonly repository?: string; +} + +function execGit(cwd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + cp.execFile('git', args, { cwd, encoding: 'utf8' }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout.trim()); + }); + }); +} + +export async function resolveGitProject(workingDirectory: URI | undefined): Promise { + if (!workingDirectory || workingDirectory.scheme !== Schemas.file) { + return undefined; + } + + const cwd = workingDirectory.fsPath; + try { + if ((await execGit(cwd, ['rev-parse', '--is-inside-work-tree'])) !== 'true') { + return undefined; + } + } catch { + return undefined; + } + + let projectPath: string | undefined; + try { + const worktreeList = await execGit(cwd, ['worktree', 'list', '--porcelain']); + projectPath = worktreeList.split(/\r?\n/).find(line => line.startsWith('worktree '))?.substring('worktree '.length); + } catch { + // Fall back to the current worktree root below. + } + + if (!projectPath) { + try { + projectPath = await execGit(cwd, ['rev-parse', '--show-toplevel']); + } catch { + return undefined; + } + } + + const uri = URI.file(projectPath); + return { uri, displayName: basename(uri.fsPath) || uri.toString() }; +} + +export function projectFromRepository(repository: string): IAgentSessionProjectInfo | undefined { + const uri = repository.includes('://') ? URI.parse(repository) : URI.parse(`https://github.com/${repository}`); + const rawDisplayName = basename(uri.path) || repository.split('/').filter(Boolean).pop() || repository; + const displayName = rawDisplayName.endsWith('.git') ? rawDisplayName.slice(0, -'.git'.length) : rawDisplayName; + return { uri, displayName }; +} + +export async function projectFromCopilotContext(context: ICopilotSessionContext | undefined): Promise { + const workingDirectory = typeof context?.cwd === 'string' + ? URI.file(context.cwd) + : typeof context?.gitRoot === 'string' + ? URI.file(context.gitRoot) + : undefined; + const gitProject = await resolveGitProject(workingDirectory); + if (gitProject) { + return gitProject; + } + + if (context?.repository) { + return projectFromRepository(context.repository); + } + + return undefined; +} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 2726b5230fa1f0..00ef6d666c8ce0 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -385,6 +385,7 @@ export class ProtocolServerHandler extends Disposable { status: s.status ?? SessionStatus.Idle, createdAt: s.startTime, modifiedAt: s.modifiedTime, + ...(s.project ? { project: { uri: s.project.uri.toString(), displayName: s.project.displayName } } : {}), workingDirectory: s.workingDirectory?.toString(), isRead: s.isRead, isDone: s.isDone, diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index d91734aa756853..e337ac1b92c880 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -32,6 +32,7 @@ function makeSessionState(sessionUri: string, overrides?: Partial status: SessionStatus.Idle, createdAt: 1, modifiedAt: 1, + project: { uri: 'file:///test-project', displayName: 'Test Project' }, }, lifecycle: SessionLifecycle.Ready, turns: [], diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index bb9a6530148311..7d66859f3a610d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -28,6 +28,7 @@ suite('AgentHostStateManager', () => { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, }; } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 44718849a2c861..8533a7280e6dc3 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -266,7 +266,7 @@ suite('AgentService (node dispatcher)', () => { test('restores a session with message history', async () => { service.registerProvider(copilotAgent); - const session = await copilotAgent.createSession(); + const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); const sessionResource = sessions[0].session; @@ -290,7 +290,7 @@ suite('AgentService (node dispatcher)', () => { test('restores a session with tool calls', async () => { service.registerProvider(copilotAgent); - const session = await copilotAgent.createSession(); + const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); const sessionResource = sessions[0].session; @@ -317,7 +317,7 @@ suite('AgentService (node dispatcher)', () => { test('flushes interrupted turns', async () => { service.registerProvider(copilotAgent); - const session = await copilotAgent.createSession(); + const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); const sessionResource = sessions[0].session; @@ -346,7 +346,7 @@ suite('AgentService (node dispatcher)', () => { test('restores a session with subagent tool calls', async () => { service.registerProvider(copilotAgent); - const session = await copilotAgent.createSession(); + const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); const sessionResource = sessions[0].session; @@ -414,7 +414,7 @@ suite('AgentService (node dispatcher)', () => { test('inner assistant messages from subagent do not create extra turns (fixture)', async () => { service.registerProvider(copilotAgent); - const session = await copilotAgent.createSession(); + const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); const sessionResource = sessions[0].session; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 4445ffa0f78e7a..48100474588969 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -46,6 +46,7 @@ suite('AgentSideEffects', () => { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }); stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); @@ -783,6 +784,7 @@ suite('AgentSideEffects', () => { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, }); localSideEffects.handleAction({ @@ -825,7 +827,7 @@ suite('AgentSideEffects', () => { localService.registerProvider(localAgent); // Create a session on the agent backend - const session = await localAgent.createSession(); + const { session } = await localAgent.createSession(); const sessions = await localAgent.listSessions(); const sessionResource = sessions[0].session; diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts new file mode 100644 index 00000000000000..6ae1694c4f269c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -0,0 +1,117 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as cp from 'child_process'; +import * as fs from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Promises } from '../../../../base/node/pfs.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { getRandomTestPath } from '../../../../base/test/node/testUtils.js'; +import { projectFromCopilotContext, projectFromRepository, resolveGitProject } from '../../node/copilot/copilotGitProject.js'; + +function execGit(cwd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + cp.execFile('git', args, { cwd, encoding: 'utf8' }, (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + return; + } + resolve(stdout.trim()); + }); + }); +} + +suite('Copilot Git Project', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let testDir: string; + + setup(async () => { + testDir = getRandomTestPath(tmpdir(), 'vsctests', 'copilot-git-project'); + await fs.promises.mkdir(testDir, { recursive: true }); + }); + + teardown(async () => { + await Promises.rm(testDir); + }); + + async function createRepository(name: string): Promise { + const repositoryPath = join(testDir, name); + await fs.promises.mkdir(repositoryPath, { recursive: true }); + await execGit(repositoryPath, ['init']); + await execGit(repositoryPath, ['config', 'user.email', 'test@example.com']); + await execGit(repositoryPath, ['config', 'user.name', 'Test User']); + await fs.promises.writeFile(join(repositoryPath, 'README.md'), '# Test\n'); + await execGit(repositoryPath, ['add', 'README.md']); + await execGit(repositoryPath, ['commit', '-m', 'initial']); + return repositoryPath; + } + + test('resolves a repository project from a worktree working directory', async () => { + const repositoryPath = await createRepository('source-repo'); + const canonicalRepositoryPath = await fs.promises.realpath(repositoryPath); + const worktreePath = join(testDir, 'worktree-checkout'); + await execGit(repositoryPath, ['worktree', 'add', worktreePath]); + + const project = await resolveGitProject(URI.file(worktreePath)); + + assert.deepStrictEqual({ + uri: project?.uri.toString(), + displayName: project?.displayName, + }, { + uri: URI.file(canonicalRepositoryPath).toString(), + displayName: 'source-repo', + }); + }); + + test('resolves the repository itself for a normal git working directory', async () => { + const repositoryPath = await createRepository('normal-repo'); + const canonicalRepositoryPath = await fs.promises.realpath(repositoryPath); + + const project = await resolveGitProject(URI.file(repositoryPath)); + + assert.deepStrictEqual({ + uri: project?.uri.toString(), + displayName: project?.displayName, + }, { + uri: URI.file(canonicalRepositoryPath).toString(), + displayName: 'normal-repo', + }); + }); + + test('returns undefined outside a git working tree', async () => { + const folder = join(testDir, 'plain-folder'); + await fs.promises.mkdir(folder); + + assert.strictEqual(await resolveGitProject(URI.file(folder)), undefined); + }); + + test('falls back to repository context when no git project is available', async () => { + const project = await projectFromCopilotContext({ repository: 'microsoft/vscode' }); + + assert.deepStrictEqual({ + uri: project?.uri.toString(), + displayName: project?.displayName, + }, { + uri: 'https://github.com/microsoft/vscode', + displayName: 'vscode', + }); + }); + + test('parses repository URLs', () => { + const project = projectFromRepository('https://github.com/microsoft/vscode.git'); + + assert.deepStrictEqual({ + uri: project?.uri.toString(), + displayName: project?.displayName, + }, { + uri: 'https://github.com/microsoft/vscode.git', + displayName: 'vscode', + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 534ab922e8f679..46a5f19fb00812 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -8,13 +8,17 @@ import { Emitter } from '../../../../base/common/event.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentSubagentStartedEvent, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentSubagentStartedEvent, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; import { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ export const MOCK_AUTO_TITLE = 'Automatically generated title'; +function mockProject(provider: AgentProvider) { + return { uri: URI.from({ scheme: 'mock-project', path: `/${provider}` }), displayName: `Agent ${provider}` }; +} + /** * General-purpose mock agent for unit tests. Tracks all method calls * for assertion and exposes {@link fireProgress} to inject progress events. @@ -64,14 +68,14 @@ export class MockAgent implements IAgent { } async listSessions(): Promise { - return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), ...this.sessionMetadataOverrides })); + return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), project: mockProject(this.id), ...this.sessionMetadataOverrides })); } - async createSession(_config?: IAgentCreateSessionConfig): Promise { + async createSession(_config?: IAgentCreateSessionConfig): Promise { const rawId = `${this.id}-session-${this._nextId++}`; const session = AgentSession.uri(this.id, rawId); this._sessions.set(rawId, session); - return session; + return { session, project: mockProject(this.id) }; } async sendMessage(session: URI, prompt: string): Promise { @@ -196,14 +200,14 @@ export class ScriptedMockAgent implements IAgent { } async listSessions(): Promise { - return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined })); + return [...this._sessions.values()].map(s => ({ session: s, startTime: Date.now(), modifiedTime: Date.now(), project: mockProject(this.id), summary: s.toString() === PRE_EXISTING_SESSION_URI.toString() ? 'Pre-existing session' : undefined })); } - async createSession(_config?: IAgentCreateSessionConfig): Promise { + async createSession(_config?: IAgentCreateSessionConfig): Promise { const rawId = `mock-session-${this._nextId++}`; const session = AgentSession.uri('mock', rawId); this._sessions.set(rawId, session); - return session; + return { session, project: mockProject(this.id) }; } async sendMessage(session: URI, prompt: string, _attachments?: IAgentAttachment[]): Promise { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index f8df9cf4f7a3aa..0db377c96e5260 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -10,7 +10,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import type { IAgentCreateSessionConfig, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../common/agentService.js'; -import { IResourceReadResult } from '../../common/state/protocol/commands.js'; +import { IListSessionsResult, IResourceReadResult } from '../../common/state/protocol/commands.js'; import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IResourceListResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; @@ -71,6 +71,7 @@ class MockAgentService implements IAgentService { readonly handledActions: ISessionAction[] = []; readonly browsedUris: URI[] = []; readonly browseErrors = new Map(); + readonly listedSessions: IAgentSessionMetadata[] = []; private readonly _onDidAction = new Emitter(); readonly onDidAction = this._onDidAction.event; @@ -89,9 +90,22 @@ class MockAgentService implements IAgentService { const origin = { clientId, clientSeq }; this._stateManager.dispatchClientAction(action, origin); } - async createSession(_config?: IAgentCreateSessionConfig): Promise { return URI.parse('copilot:///new-session'); } + async createSession(config?: IAgentCreateSessionConfig): Promise { + const session = config?.session ?? URI.parse('copilot:///new-session'); + this._stateManager.createSession({ + resource: session.toString(), + provider: config?.provider ?? 'copilot', + title: 'New Session', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + project: { uri: 'file:///created-project', displayName: 'Created Project' }, + workingDirectory: config?.workingDirectory?.toString(), + }); + return session; + } async disposeSession(_session: URI): Promise { } - async listSessions(): Promise { return []; } + async listSessions(): Promise { return this.listedSessions; } async subscribe(resource: URI): Promise { const snapshot = this._stateManager.getSnapshot(resource.toString()); if (!snapshot) { @@ -173,6 +187,7 @@ suite('ProtocolServerHandler', () => { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, }; } @@ -308,6 +323,67 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(findNotifications(transportB.sent, 'notification').length, 1); }); + test('listSessions includes project metadata', async () => { + agentService.listedSessions.push({ + session: URI.parse(sessionUri), + startTime: 1000, + modifiedTime: 2000, + project: { uri: URI.file('/workspace/project'), displayName: 'Project' }, + summary: 'Session Summary', + }); + + const transport = connectClient('client-list'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 2); + + transport.simulateMessage(request(2, 'listSessions')); + const resp = await responsePromise; + + const result = (resp as unknown as { result: IListSessionsResult }).result; + assert.deepStrictEqual(result.items.map(item => item.project), [{ uri: URI.file('/workspace/project').toString(), displayName: 'Project' }]); + }); + + test('listSessions omits project metadata when absent', async () => { + agentService.listedSessions.push({ + session: URI.parse(sessionUri), + startTime: 1000, + modifiedTime: 2000, + summary: 'Session Summary', + }); + + const transport = connectClient('client-list-no-project'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 2); + + transport.simulateMessage(request(2, 'listSessions')); + const resp = await responsePromise; + + const result = (resp as unknown as { result: IListSessionsResult }).result; + assert.deepStrictEqual(result.items.map(item => item.project), [undefined]); + }); + + test('createSession returns null and broadcasts project in sessionAdded summary', async () => { + const transport = connectClient('client-create'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 2); + + const newSession = URI.parse('copilot:///created-session').toString(); + transport.simulateMessage(request(2, 'createSession', { session: newSession })); + const resp = await responsePromise; + + const added = findNotifications(transport.sent, 'notification').find(message => { + const params = message.params as { notification: { type: string } }; + return params.notification.type === 'notify/sessionAdded'; + }); + assert.deepStrictEqual({ + result: (resp as { result: null }).result, + project: (added!.params as { notification: { summary: ISessionSummary } }).notification.summary.project, + }, { + result: null, + project: { uri: 'file:///created-project', displayName: 'Created Project' }, + }); + }); + test('reconnect replays missed actions', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index 3368b870f35043..dee1e65a635fbc 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -18,6 +18,7 @@ function makeSession(): ISessionState { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, }, lifecycle: SessionLifecycle.Ready, turns: [], diff --git a/src/vs/sessions/README.md b/src/vs/sessions/README.md index 5903e497d3a3df..d135d3f07c1ba3 100644 --- a/src/vs/sessions/README.md +++ b/src/vs/sessions/README.md @@ -296,6 +296,12 @@ RemoteAgentHostSessionsProvider └── Creates session on the remote agent host ``` +Agent-host session metadata may include a server-owned project. When present, +providers map that project to the session workspace's source repository URI and +label, while keeping the working directory as the repository working directory. +This lets the sessions view group agent-host sessions by project/source repo +instead of by the isolated worktree or checkout folder. + ### Data Flow #### Creating a New Session diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts index 3f5252a42e03eb..1fd1ca2e312277 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -26,6 +26,16 @@ import { ILanguageModelsService } from '../../../../workbench/contrib/chat/commo import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; import { IChat, ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, type IGitHubInfo, ISessionType } from '../../../services/sessions/common/session.js'; +interface ISessionProjectSummary { + readonly uri: URI; + readonly displayName: string; +} + +function workspaceKey(workspace: ISessionWorkspace | undefined): string | undefined { + const repository = workspace?.repositories[0]; + return workspace && repository ? `${workspace.label}\n${repository.uri.toString()}\n${repository.workingDirectory?.toString() ?? ''}` : undefined; +} + const LOCAL_PROVIDER_ID = 'local-agent-host'; /** Default provider when session metadata does not carry one. */ @@ -96,9 +106,7 @@ class LocalSessionAdapter implements ISession { this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); this.description = observableValue('description', new MarkdownString().appendText(localize('localAgentHostDescription', "Local"))); - this.workspace = observableValue('workspace', metadata.workingDirectory - ? LocalAgentHostSessionsProvider.buildWorkspace(metadata.workingDirectory) - : undefined); + this.workspace = observableValue('workspace', LocalAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory)); if (metadata.isRead === false) { this.isRead.set(false, undefined); @@ -146,6 +154,12 @@ class LocalSessionAdapter implements ISession { didChange = true; } + const workspace = LocalAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory); + if (workspaceKey(workspace) !== workspaceKey(this.workspace.get())) { + this.workspace.set(workspace, undefined); + didChange = true; + } + if (metadata.isRead !== undefined && metadata.isRead !== this.isRead.get()) { this.isRead.set(metadata.isRead, undefined); didChange = true; @@ -249,7 +263,21 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi // -- Workspaces -- - static buildWorkspace(workingDirectory: URI): ISessionWorkspace { + static buildWorkspace(project: ISessionProjectSummary | undefined, workingDirectory: URI | undefined): ISessionWorkspace | undefined { + if (project) { + const repositoryWorkingDirectory = workingDirectory?.toString() !== project.uri.toString() ? workingDirectory : undefined; + return { + label: project.displayName, + icon: Codicon.repo, + repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: true, + }; + } + + if (!workingDirectory) { + return undefined; + } + const folderName = basename(workingDirectory) || workingDirectory.path; return { label: folderName, @@ -566,7 +594,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi } } - private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void { + private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; project?: { uri: string; displayName: string }; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); if (this._sessionCache.has(rawId)) { @@ -581,6 +609,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi startTime: summary.createdAt, modifiedTime: summary.modifiedAt, summary: summary.title, + ...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}), workingDirectory: workingDir, isRead: summary.isRead, isDone: summary.isDone, diff --git a/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts index f7816c446795c9..3675b895eefb34 100644 --- a/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/localAgentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -87,12 +87,13 @@ class MockAgentHostService extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { return { session: AgentSession.uri(opts?.provider ?? 'copilot', id), startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, + project: opts?.project, workingDirectory: opts?.workingDirectory, }; } @@ -120,7 +121,7 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen return disposables.add(instantiationService.createInstance(LocalAgentHostSessionsProvider)); } -function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; workingDirectory?: string }): void { +function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { const provider = opts?.provider ?? 'copilot'; const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ @@ -132,6 +133,7 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: status: ProtocolSessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + project: opts?.project, workingDirectory: opts?.workingDirectory, }, }); @@ -259,6 +261,31 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(sessions.length, 2); })); + test('uses project metadata as workspace group source', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const projectUri = URI.file('/home/user/vscode'); + const workingDirectory = URI.file('/tmp/copilot-worktrees/vscode-feature'); + agentHost.addSession(createSession('project-1', { + summary: 'Project Session', + project: { uri: projectUri, displayName: 'vscode' }, + workingDirectory, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const workspace = provider.getSessions()[0].workspace.get(); + assert.deepStrictEqual({ + label: workspace?.label, + repository: workspace?.repositories[0]?.uri.toString(), + workingDirectory: workspace?.repositories[0]?.workingDirectory?.toString(), + }, { + label: 'vscode', + repository: projectUri.toString(), + workingDirectory: workingDirectory.toString(), + }); + })); + // ---- Session lifecycle ------- test('createNewSession returns session with correct fields', () => { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 36c9f0e57425f1..87b463d5371d41 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -9,6 +9,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; import { basename } from '../../../../base/common/resources.js'; import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -30,6 +31,20 @@ import { ILanguageModelsService } from '../../../../workbench/contrib/chat/commo import { ISessionChangeEvent, ISendRequestOptions, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; import { ISession, IChat, IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, CopilotCLISessionType, ISessionType } from '../../../services/sessions/common/session.js'; +interface ISessionProjectSummary { + readonly uri: URI; + readonly displayName: string; +} + +function workspaceKey(workspace: ISessionWorkspace | undefined): string | undefined { + const repository = workspace?.repositories[0]; + return workspace && repository ? `${workspace.label}\n${repository.uri.toString()}\n${repository.workingDirectory?.toString() ?? ''}` : undefined; +} + +function toLocalProjectUri(uri: URI, connectionAuthority: string): URI { + return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri; +} + interface IChatData { /** Globally unique session ID (`providerId:localId`). */ readonly id: string; @@ -114,8 +129,7 @@ class RemoteSessionAdapter implements IChatData { providerId: string, resourceScheme: string, logicalSessionType: string, - providerLabel: string, - connectionAuthority: string, + private readonly _providerLabel: string, ) { const rawId = AgentSession.id(metadata.session); this.agentProvider = AgentSession.provider(metadata.session) ?? 'copilot'; @@ -127,10 +141,8 @@ class RemoteSessionAdapter implements IChatData { this.title = observableValue('title', metadata.summary ?? `Session ${rawId.substring(0, 8)}`); this.updatedAt = observableValue('updatedAt', new Date(metadata.modifiedTime)); this.lastTurnEnd = observableValue('lastTurnEnd', metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined); - this.description = observableValue('description', new MarkdownString().appendText(providerLabel)); - this.workspace = observableValue('workspace', metadata.workingDirectory - ? RemoteAgentHostSessionsProvider.buildWorkspace(metadata.workingDirectory, providerLabel, connectionAuthority) - : undefined); + this.description = observableValue('description', new MarkdownString().appendText(this._providerLabel)); + this.workspace = observableValue('workspace', RemoteAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory, this._providerLabel)); if (metadata.isRead === false) { this.isRead.set(false, undefined); @@ -150,6 +162,10 @@ class RemoteSessionAdapter implements IChatData { if (metadata.isDone !== undefined) { this.isArchived.set(metadata.isDone, undefined); } + const workspace = RemoteAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory, this._providerLabel); + if (workspaceKey(workspace) !== workspaceKey(this.workspace.get())) { + this.workspace.set(workspace, undefined); + } } } @@ -330,7 +346,21 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess /** * Builds workspace metadata from a working directory path on the remote host. */ - static buildWorkspace(workingDirectory: URI, providerLabel: string, _connectionAuthority: string): ISessionWorkspace { + static buildWorkspace(project: ISessionProjectSummary | undefined, workingDirectory: URI | undefined, providerLabel: string): ISessionWorkspace | undefined { + if (project) { + const repositoryWorkingDirectory = workingDirectory?.toString() !== project.uri.toString() ? workingDirectory : undefined; + return { + label: project.displayName, + icon: Codicon.repo, + repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: false, + }; + } + + if (!workingDirectory) { + return undefined; + } + const folderName = basename(workingDirectory) || workingDirectory.path; return { label: `${folderName} [${providerLabel}]`, @@ -615,7 +645,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess existing.update(meta); changed.push(this._chatToSession(existing)); } else { - const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority); + const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label); this._sessionCache.set(rawId, cached); added.push(this._chatToSession(cached)); } @@ -673,7 +703,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } - private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void { + private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; project?: { uri: string; displayName: string }; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); if (this._sessionCache.has(rawId)) { @@ -689,11 +719,12 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess startTime: summary.createdAt, modifiedTime: summary.modifiedAt, summary: summary.title, + ...(summary.project ? { project: { uri: toLocalProjectUri(URI.parse(summary.project.uri), this._connectionAuthority), displayName: summary.project.displayName } } : {}), workingDirectory: workingDir, isRead: summary.isRead, isDone: summary.isDone, }; - const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority); + const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label); this._sessionCache.set(rawId, cached); this._onDidChangeSessions.fire({ added: [this._chatToSession(cached)], removed: [], changed: [] }); } diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index d08bcafb98b6d5..a1d5f1cdd2288e 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -88,12 +88,13 @@ class MockAgentConnection extends mock() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { return { session: AgentSession.uri(opts?.provider ?? 'copilot', id), startTime: opts?.startTime ?? 1000, modifiedTime: opts?.modifiedTime ?? 2000, summary: opts?.summary, + project: opts?.project, workingDirectory: opts?.workingDirectory, }; } @@ -129,7 +130,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne return provider; } -function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; workingDirectory?: string }): void { +function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { const provider = opts?.provider ?? 'copilot'; const sessionUri = AgentSession.uri(provider, rawId); connection.fireNotification({ @@ -141,6 +142,7 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: status: ProtocolSessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + project: opts?.project, workingDirectory: opts?.workingDirectory, }, }); @@ -258,6 +260,51 @@ suite('RemoteAgentHostSessionsProvider', () => { assert.strictEqual(changes.length, 1); }); + test('uses project metadata as workspace group source', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const projectUri = URI.parse('vscode-agent-host://localhost__4321/file/-/home/user/vscode'); + const workingDirectory = URI.parse('vscode-agent-host://localhost__4321/file/-/tmp/copilot-worktrees/vscode-feature'); + connection.addSession(createSession('project-1', { + summary: 'Project Session', + project: { uri: projectUri, displayName: 'vscode' }, + workingDirectory, + })); + + const provider = createProvider(disposables, connection); + provider.getSessions(); + await timeout(0); + + const workspace = provider.getSessions()[0].workspace.get(); + assert.deepStrictEqual({ + label: workspace?.label, + repository: workspace?.repositories[0]?.uri.toString(), + workingDirectory: workspace?.repositories[0]?.workingDirectory?.toString(), + }, { + label: 'vscode', + repository: projectUri.toString(), + workingDirectory: workingDirectory.toString(), + }); + })); + + test('session added converts file project URIs and preserves repository URLs', () => { + const provider = createProvider(disposables, connection); + + fireSessionAdded(connection, 'file-project', { + title: 'File Project', + project: { uri: 'file:///home/user/vscode', displayName: 'vscode' }, + workingDirectory: 'file:///tmp/copilot-worktrees/vscode-feature', + }); + fireSessionAdded(connection, 'url-project', { + title: 'URL Project', + project: { uri: 'https://github.com/microsoft/vscode', displayName: 'vscode' }, + }); + + const workspaces = provider.getSessions().map(session => session.workspace.get()); + assert.deepStrictEqual(workspaces.map(workspace => workspace?.repositories[0]?.uri.toString()), [ + 'vscode-agent-host://localhost__4321/file/-/home/user/vscode', + 'https://github.com/microsoft/vscode', + ]); + }); + test('removing non-existent session is no-op', () => { const provider = createProvider(disposables, connection); const changes: ISessionChangeEvent[] = []; From 9278c9357d3bce118bb425028e8b50831589b235 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 10 Apr 2026 15:03:12 -0700 Subject: [PATCH 2/3] Persist agent session project resolution (Written by Copilot) --- .../agentHost/node/copilot/copilotAgent.ts | 88 ++++++++++++++++--- .../common/agentHostSessionWorkspace.ts | 50 +++++++++++ .../browser/localAgentHostSessionsProvider.ts | 37 +------- .../remoteAgentHostSessionsProvider.ts | 37 +------- 4 files changed, 135 insertions(+), 77 deletions(-) create mode 100644 src/vs/sessions/common/agentHostSessionWorkspace.ts diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 4b404da3633205..18161f5ae19b81 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -5,7 +5,7 @@ import { CopilotClient } from '@github/copilot-sdk'; import { rgPath } from '@vscode/ripgrep'; -import { SequencerByKey } from '../../../../base/common/async.js'; +import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; @@ -17,7 +17,7 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js'; import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js'; @@ -26,7 +26,7 @@ import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js'; import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; -import { projectFromCopilotContext } from './copilotGitProject.js'; +import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { createShellTools, ShellManager } from './copilotShellTools.js'; /** @@ -162,10 +162,17 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing sessions...'); const client = await this._ensureClient(); const sessions = await client.listSessions(); + const projectLimiter = new Limiter(4); + const projectByContext = new Map>(); const result: IAgentSessionMetadata[] = await Promise.all(sessions.map(async s => { - const project = await projectFromCopilotContext(s.context); + const session = AgentSession.uri(this.id, s.sessionId); + let { project, resolved } = await this._readSessionProject(session); + if (!resolved) { + project = await this._resolveSessionProject(s.context, projectLimiter, projectByContext); + this._storeSessionProjectResolution(session, project); + } return { - session: AgentSession.uri(this.id, s.sessionId), + session, startTime: s.startTime.getTime(), modifiedTime: s.modifiedTime.getTime(), ...(project ? { project } : {}), @@ -226,6 +233,7 @@ export class CopilotAgent extends Disposable implements IAgent { const session = agentSession.sessionUri; this._logService.info(`[Copilot] Forked session created: ${session.toString()}`); const project = await projectFromCopilotContext({ cwd: config.workingDirectory?.fsPath }); + this._storeSessionMetadata(session, undefined, config.workingDirectory, project, true); return { session, ...(project ? { project } : {}) }; }); } @@ -250,13 +258,12 @@ export class CopilotAgent extends Disposable implements IAgent { this._plugins.setAppliedPlugins(agentSession, parsedPlugins); await agentSession.initializeSession(); - // Persist model & working directory so we can recreate the session - // if the SDK loses it (e.g. sessions without messages). - this._storeSessionMetadata(agentSession.sessionUri, config?.model, config?.workingDirectory); - const session = agentSession.sessionUri; this._logService.info(`[Copilot] Session created: ${session.toString()}`); const project = await projectFromCopilotContext({ cwd: config?.workingDirectory?.fsPath }); + // Persist model, working directory, and project so we can recreate the + // session if the SDK loses it and avoid rediscovering git metadata. + this._storeSessionMetadata(agentSession.sessionUri, config?.model, config?.workingDirectory, project, true); return { session, ...(project ? { project } : {}) }; } @@ -372,7 +379,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (entry) { await entry.setModel(model); } - this._storeSessionMetadata(session, model, undefined); + this._storeSessionMetadata(session, model, undefined, undefined); } async shutdown(): Promise { @@ -503,8 +510,11 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_MODEL = 'copilot.model'; private static readonly _META_CWD = 'copilot.workingDirectory'; + private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved'; + private static readonly _META_PROJECT_URI = 'copilot.project.uri'; + private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName'; - private _storeSessionMetadata(session: URI, model: string | undefined, workingDirectory: URI | undefined): void { + private _storeSessionMetadata(session: URI, model: string | undefined, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): void { const dbRef = this._sessionDataService.tryOpenDatabase(session); dbRef?.then(ref => { if (!ref) { @@ -518,6 +528,13 @@ export class CopilotAgent extends Disposable implements IAgent { if (workingDirectory) { work.push(db.setMetadata(CopilotAgent._META_CWD, workingDirectory.toString())); } + if (projectResolved) { + work.push(db.setMetadata(CopilotAgent._META_PROJECT_RESOLVED, 'true')); + } + if (project) { + work.push(db.setMetadata(CopilotAgent._META_PROJECT_URI, project.uri.toString())); + work.push(db.setMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME, project.displayName)); + } Promise.all(work).finally(() => ref.dispose()); }); } @@ -541,6 +558,55 @@ export class CopilotAgent extends Disposable implements IAgent { } } + private async _readSessionProject(session: URI): Promise<{ project?: IAgentSessionProjectInfo; resolved: boolean }> { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return { resolved: false }; + } + try { + const [resolved, uri, displayName] = await Promise.all([ + ref.object.getMetadata(CopilotAgent._META_PROJECT_RESOLVED), + ref.object.getMetadata(CopilotAgent._META_PROJECT_URI), + ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME), + ]); + const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined; + return { project, resolved: resolved === 'true' || project !== undefined }; + } finally { + ref.dispose(); + } + } + + private _storeSessionProjectResolution(session: URI, project: IAgentSessionProjectInfo | undefined): void { + this._storeSessionMetadata(session, undefined, undefined, project, true); + } + + private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter, projectByContext: Map>): Promise { + const key = this._projectContextKey(context); + if (!key) { + return Promise.resolve(undefined); + } + + let project = projectByContext.get(key); + if (!project) { + project = limiter.queue(() => projectFromCopilotContext(context)); + projectByContext.set(key, project); + } + return project; + } + + private _projectContextKey(context: ICopilotSessionContext | undefined): string | undefined { + if (context?.cwd) { + return `cwd:${context.cwd}`; + } + if (context?.gitRoot) { + return `gitRoot:${context.gitRoot}`; + } + if (context?.repository) { + return `repository:${context.repository}`; + } + return undefined; + } + override dispose(): void { this._client?.stop().catch(() => { /* best-effort */ }); super.dispose(); diff --git a/src/vs/sessions/common/agentHostSessionWorkspace.ts b/src/vs/sessions/common/agentHostSessionWorkspace.ts new file mode 100644 index 00000000000000..136d190bb4595e --- /dev/null +++ b/src/vs/sessions/common/agentHostSessionWorkspace.ts @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../base/common/codicons.js'; +import { extUri, basename } from '../../base/common/resources.js'; +import { ThemeIcon } from '../../base/common/themables.js'; +import { URI } from '../../base/common/uri.js'; +import { ISessionWorkspace } from '../services/sessions/common/session.js'; + +export interface IAgentHostSessionProjectSummary { + readonly uri: URI; + readonly displayName: string; +} + +export interface IAgentHostSessionWorkspaceOptions { + readonly providerLabel?: string; + readonly fallbackIcon: ThemeIcon; + readonly requiresWorkspaceTrust: boolean; +} + +export function agentHostSessionWorkspaceKey(workspace: ISessionWorkspace | undefined): string | undefined { + const repository = workspace?.repositories[0]; + return workspace && repository ? `${workspace.label}\n${extUri.getComparisonKey(repository.uri)}\n${repository.workingDirectory ? extUri.getComparisonKey(repository.workingDirectory) : ''}` : undefined; +} + +export function buildAgentHostSessionWorkspace(project: IAgentHostSessionProjectSummary | undefined, workingDirectory: URI | undefined, options: IAgentHostSessionWorkspaceOptions): ISessionWorkspace | undefined { + if (project) { + const repositoryWorkingDirectory = extUri.isEqual(workingDirectory, project.uri) ? undefined : workingDirectory; + return { + label: project.displayName, + icon: Codicon.repo, + repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: options.providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: options.requiresWorkspaceTrust, + }; + } + + if (!workingDirectory) { + return undefined; + } + + const folderName = basename(workingDirectory) || workingDirectory.path; + return { + label: options.providerLabel ? `${folderName} [${options.providerLabel}]` : folderName, + icon: options.fallbackIcon, + repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: options.providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }], + requiresWorkspaceTrust: options.requiresWorkspaceTrust, + }; +} diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts index 1fd1ca2e312277..7a1b2259d168e9 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -23,19 +23,10 @@ import { IChatSendRequestOptions, IChatService } from '../../../../workbench/con import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { agentHostSessionWorkspaceKey, buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; import { ISendRequestOptions, ISessionChangeEvent, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; import { IChat, ISession, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, type IGitHubInfo, ISessionType } from '../../../services/sessions/common/session.js'; -interface ISessionProjectSummary { - readonly uri: URI; - readonly displayName: string; -} - -function workspaceKey(workspace: ISessionWorkspace | undefined): string | undefined { - const repository = workspace?.repositories[0]; - return workspace && repository ? `${workspace.label}\n${repository.uri.toString()}\n${repository.workingDirectory?.toString() ?? ''}` : undefined; -} - const LOCAL_PROVIDER_ID = 'local-agent-host'; /** Default provider when session metadata does not carry one. */ @@ -155,7 +146,7 @@ class LocalSessionAdapter implements ISession { } const workspace = LocalAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory); - if (workspaceKey(workspace) !== workspaceKey(this.workspace.get())) { + if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) { this.workspace.set(workspace, undefined); didChange = true; } @@ -263,28 +254,8 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi // -- Workspaces -- - static buildWorkspace(project: ISessionProjectSummary | undefined, workingDirectory: URI | undefined): ISessionWorkspace | undefined { - if (project) { - const repositoryWorkingDirectory = workingDirectory?.toString() !== project.uri.toString() ? workingDirectory : undefined; - return { - label: project.displayName, - icon: Codicon.repo, - repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], - requiresWorkspaceTrust: true, - }; - } - - if (!workingDirectory) { - return undefined; - } - - const folderName = basename(workingDirectory) || workingDirectory.path; - return { - label: folderName, - icon: Codicon.folder, - repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], - requiresWorkspaceTrust: true, - }; + static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined): ISessionWorkspace | undefined { + return buildAgentHostSessionWorkspace(project, workingDirectory, { fallbackIcon: Codicon.folder, requiresWorkspaceTrust: true }); } resolveWorkspace(repositoryUri: URI): ISessionWorkspace { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 87b463d5371d41..b23ada46517d25 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -28,19 +28,10 @@ import { IChatSendRequestOptions, IChatService } from '../../../../workbench/con import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { agentHostSessionWorkspaceKey, buildAgentHostSessionWorkspace } from '../../../common/agentHostSessionWorkspace.js'; import { ISessionChangeEvent, ISendRequestOptions, ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js'; import { ISession, IChat, IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SessionStatus, CopilotCLISessionType, ISessionType } from '../../../services/sessions/common/session.js'; -interface ISessionProjectSummary { - readonly uri: URI; - readonly displayName: string; -} - -function workspaceKey(workspace: ISessionWorkspace | undefined): string | undefined { - const repository = workspace?.repositories[0]; - return workspace && repository ? `${workspace.label}\n${repository.uri.toString()}\n${repository.workingDirectory?.toString() ?? ''}` : undefined; -} - function toLocalProjectUri(uri: URI, connectionAuthority: string): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri; } @@ -163,7 +154,7 @@ class RemoteSessionAdapter implements IChatData { this.isArchived.set(metadata.isDone, undefined); } const workspace = RemoteAgentHostSessionsProvider.buildWorkspace(metadata.project, metadata.workingDirectory, this._providerLabel); - if (workspaceKey(workspace) !== workspaceKey(this.workspace.get())) { + if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) { this.workspace.set(workspace, undefined); } } @@ -346,28 +337,8 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess /** * Builds workspace metadata from a working directory path on the remote host. */ - static buildWorkspace(project: ISessionProjectSummary | undefined, workingDirectory: URI | undefined, providerLabel: string): ISessionWorkspace | undefined { - if (project) { - const repositoryWorkingDirectory = workingDirectory?.toString() !== project.uri.toString() ? workingDirectory : undefined; - return { - label: project.displayName, - icon: Codicon.repo, - repositories: [{ uri: project.uri, workingDirectory: repositoryWorkingDirectory, detail: providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }], - requiresWorkspaceTrust: false, - }; - } - - if (!workingDirectory) { - return undefined; - } - - const folderName = basename(workingDirectory) || workingDirectory.path; - return { - label: `${folderName} [${providerLabel}]`, - icon: Codicon.remote, - repositories: [{ uri: workingDirectory, workingDirectory: undefined, detail: providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }], - requiresWorkspaceTrust: false, - }; + static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string): ISessionWorkspace | undefined { + return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false }); } private _buildWorkspaceFromUri(uri: URI): ISessionWorkspace { From 59407c6aadd690611f9785eea259a6aa51073be2 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 10 Apr 2026 15:22:01 -0700 Subject: [PATCH 3/3] sync ahp --- src/vs/platform/agentHost/common/state/protocol/.ahp-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 1b882c1574e62a..c76d492dc49b9a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -8a85cec +1f72258