diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index cb864567209e0..6dd14f82446da 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -536,6 +536,8 @@ export function resolveCopilotOtlpMetricsEndpoint(endpoint: string, protocol: 'h /** `origin` value written by the VS Code extension-host Copilot CLI feature. */ const EXTENSION_HOST_CLI_MARKER_ORIGIN = 'vscode'; +const COPILOT_EXTERNAL_SESSION_CLIENT_NAMES = new Set(['github/cli', 'github/autopilot']); +const COPILOT_EXTERNAL_SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; /** * Shape of the `vscode.metadata.json` marker written next to a Copilot CLI @@ -570,6 +572,7 @@ const NANO_AIU_PER_CREDIT = 1_000_000_000; */ export class CopilotAgent extends Disposable implements IAgent { readonly id = 'copilotcli' as const; + protected readonly _now = Date.now; private readonly _onDidChatProgress = this._register(new Emitter()); readonly onDidChatProgress = this._onDidChatProgress.event; @@ -2088,7 +2091,7 @@ export class CopilotAgent extends Disposable implements IAgent { } async listChatsToMigrate(): Promise { - const sessions = await this._listSdkSessions('chats to migrate'); + const sessions = await this._listSdkSessions('chats to migrate', client => client.listSessions()); if (!sessions) { return undefined; } @@ -2192,9 +2195,11 @@ export class CopilotAgent extends Disposable implements IAgent { * * - a legacy extension-host Copilot CLI chat is *internal* and adoptable in * place (see {@link ensureChatAdopted}), so it keeps `external: false`; - * - anything else was produced by another client sharing the same Copilot - * home (the standalone CLI, the GitHub Copilot app, another editor) and - * is therefore `external: true`. + * - a non-adoptable chat is external only when its persisted `clientName` + * identifies the standalone CLI or GitHub Copilot app. This value records + * the runtime client that created or last resumed the chat, not immutable + * creator provenance. External chats must also have repository metadata + * and have been modified within the last seven days. * * A chat counts as already known when it has a per-session database, which * also keeps peer-chat backings out of the result. A chat the SDK reports @@ -2211,15 +2216,19 @@ export class CopilotAgent extends Disposable implements IAgent { * authoritative empty result. */ private async _discoverCopilotChats(): Promise { - const sessions = await this._listSdkSessions('discoverable chats'); + const sessions = await this._listSdkSessions('discoverable chats', async client => (await client.rpc.sessions.list({})).sessions); if (!sessions) { return undefined; } const projectLimiter = new Limiter(4); const metadataLimiter = new Limiter(4); const projectByContext = new Map>(); + const earliestExternalModifiedTime = this._now() - COPILOT_EXTERNAL_SESSION_MAX_AGE_MS; let known = 0; let withoutWorkingDirectory = 0; + let unsupportedClientName = 0; + let outsideImportWindow = 0; + let withoutRepository = 0; let failed = 0; const mapped = await Promise.all(sessions.map(s => metadataLimiter.queue(async () => { const session = AgentSession.uri(this.id, s.sessionId); @@ -2228,18 +2237,34 @@ export class CopilotAgent extends Disposable implements IAgent { known++; return undefined; } - if (typeof s.context?.workingDirectory !== 'string') { + if (typeof s.context?.cwd !== 'string') { withoutWorkingDirectory++; return undefined; } const adoptable = await this._isExtensionHostCliSession(s.sessionId); + const modifiedTime = new Date(s.modifiedTime).getTime(); + if (!adoptable) { + const clientName = s.isRemote ? undefined : s.clientName; + if (clientName === undefined || !COPILOT_EXTERNAL_SESSION_CLIENT_NAMES.has(clientName)) { + unsupportedClientName++; + return undefined; + } + if (!Number.isFinite(modifiedTime) || modifiedTime < earliestExternalModifiedTime) { + outsideImportWindow++; + return undefined; + } + if (typeof s.context.repository !== 'string' || s.context.repository.trim().length === 0) { + withoutRepository++; + return undefined; + } + } return { chat: URI.parse(buildDefaultChatUri(session)), - startTime: s.startTime.getTime(), - modifiedTime: s.modifiedTime.getTime(), + startTime: new Date(s.startTime).getTime(), + modifiedTime, project: await this._resolveSessionProject(s.context, projectLimiter, projectByContext), summary: s.summary, - workingDirectories: [URI.file(s.context.workingDirectory)], + workingDirectories: [URI.file(s.context.cwd)], _meta: adoptable ? withSessionEhcliAdoptable(undefined) : undefined, external: !adoptable, } satisfies IAgentDiscoveredChat; @@ -2251,14 +2276,14 @@ export class CopilotAgent extends Disposable implements IAgent { }))); const chats = mapped.filter((chat): chat is IAgentDiscoveredChat => chat !== undefined); const external = chats.filter(chat => chat.external).length; - this._logService.info(`[Copilot] Chat discovery: ${sessions.length} SDK session(s) -> ${external} external, ${chats.length - external} adoptable legacy extension-host, ${known} already known to Agent Host, ${withoutWorkingDirectory} without a working directory, ${failed} failed to classify`); + this._logService.info(`[Copilot] Chat discovery: ${sessions.length} SDK session(s) -> ${external} external, ${chats.length - external} adoptable legacy extension-host, ${known} already known to Agent Host, ${withoutWorkingDirectory} without a working directory, ${unsupportedClientName} with unsupported or missing client name, ${outsideImportWindow} outside the import window, ${withoutRepository} without repository metadata, ${failed} failed to classify`); return chats; } - private async _listSdkSessions(reason: string): Promise> | undefined> { + private async _listSdkSessions(reason: string, listSessions: (client: CopilotClient) => Promise): Promise { this._logService.info(`[Copilot] Listing ${reason}...`); try { - const sessions = await this._retryAfterClosedConnection('listSessions', client => client.listSessions()); + const sessions = await this._retryAfterClosedConnection('listSessions', listSessions); this._logService.info(`[Copilot] Listed ${sessions.length} SDK session(s) for ${reason}`); return sessions; } catch (err) { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1b0d5e6422403..88aa23c0c5684 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -432,11 +432,22 @@ interface ITestCopilotModelInfo { interface ITestCopilotClient extends Pick { readonly rpc: { - readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] }; + readonly sessions: { + readonly fork: CopilotClient['rpc']['sessions']['fork']; + readonly list: CopilotClient['rpc']['sessions']['list']; + }; readonly models: { readonly list: CopilotModelsList }; }; } +type TestCopilotSessionMetadata = Awaited>[number] & { readonly clientName?: string }; + +interface ITestCopilotSessionOptions { + readonly clientName?: string; + readonly repository?: string; + readonly modifiedTime?: Date; +} + function toSdkModelInfo(model: ITestCopilotModelInfo): CopilotModelInfo { return { id: model.id, @@ -462,7 +473,31 @@ function toSdkModelInfo(model: ITestCopilotModelInfo): CopilotModelInfo { class TestCopilotClient implements ITestCopilotClient { readonly rpc: ITestCopilotClient['rpc'] = { - sessions: { fork: async () => ({ sessionId: 'forked-session' }) }, + sessions: { + fork: async () => ({ sessionId: 'forked-session' }), + list: async () => { + this.sessionListStarted?.complete(); + await this.sessionListGate; + return { + sessions: this._sessions.map(session => ({ + sessionId: session.sessionId, + startTime: session.startTime.toISOString(), + modifiedTime: session.modifiedTime.toISOString(), + summary: session.summary, + clientName: session.clientName, + isRemote: false, + ...(session.context ? { + context: { + cwd: session.context.workingDirectory, + gitRoot: session.context.gitRoot, + repository: session.context.repository, + branch: session.context.branch, + } + } : {}), + })) + }; + }, + }, models: { list: async params => { this.modelListRequests.push(params); @@ -483,6 +518,8 @@ class TestCopilotClient implements ITestCopilotClient { startGate: Promise | undefined; startError: Error | undefined; listSessionCallCount = 0; + sessionListStarted: DeferredPromise | undefined; + sessionListGate: Promise | undefined; readonly modelListRequests: Parameters[0][] = []; readonly modelListErrors: Error[] = []; /** When set, `models.list` records its request then blocks on this until resolved. */ @@ -494,7 +531,7 @@ class TestCopilotClient implements ITestCopilotClient { readonly deletedSessionIds: string[] = []; constructor( - private readonly _sessions: Awaited>, + private readonly _sessions: TestCopilotSessionMetadata[], private readonly _models: readonly ITestCopilotModelInfo[] = [], ) { } @@ -734,6 +771,7 @@ class TestableCopilotAgent extends CopilotAgent { readonly resumeCalls: string[] = []; readonly createdClientOptions: CopilotClientOptions[] = []; lastClientOptions: CopilotClientOptions | undefined; + protected override readonly _now: () => number; // Keep model-refresh retries effectively instant in tests. protected override readonly _modelRefreshBaseDelayMs = 1; @@ -741,6 +779,7 @@ class TestableCopilotAgent extends CopilotAgent { constructor( private readonly _copilotClient: ITestCopilotClient, + now: () => number, @ILogService logService: ILogService, @IInstantiationService instantiationService: IInstantiationService, @ISessionDataService sessionDataService: ISessionDataService, @@ -759,6 +798,7 @@ class TestableCopilotAgent extends CopilotAgent { @ICopilotApiService copilotApiService: ICopilotApiService, ) { super(logService, instantiationService, sessionDataService, gitService, configurationService, sessionTitleSignal, managedSettingsService, gitHubEndpointService, otelService, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, customizationEnablementService, environmentService, byokBridgeRegistry, telemetryService, copilotApiService, proxyResolver); + this._now = now; } protected override _createCopilotClient(options: CopilotClientOptions): CopilotClient { @@ -812,7 +852,7 @@ function getCreatedClientOptions(agent: CopilotAgent): readonly CopilotClientOpt return agent.createdClientOptions; } -function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService; rootConfig?: Record }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; managedSettingsService: IAgentHostManagedSettingsService; fileService: FileService; stateManager: AgentHostStateManager } { +function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService; rootConfig?: Record; now?: () => number }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; managedSettingsService: IAgentHostManagedSettingsService; fileService: FileService; stateManager: AgentHostStateManager } { const services = new ServiceCollection(); const logService = options?.logService ?? new NullLogService(); const fileService = options?.fileService ?? disposables.add(new FileService(logService)); @@ -879,7 +919,9 @@ function createTestAgentContext(disposables: Pick, optio const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); services.set(IInstantiationService, instantiationService); const agent = options?.copilotClient - ? instantiationService.createInstance(options.useRealResumePath ? ResumePathCopilotAgent : TestableCopilotAgent, options.copilotClient) + ? options.useRealResumePath + ? instantiationService.createInstance(ResumePathCopilotAgent, options.copilotClient) + : instantiationService.createInstance(TestableCopilotAgent, options.copilotClient, options.now ?? Date.now) : instantiationService.createInstance(CopilotAgent); return { agent, instantiationService, configurationService: configService, managedSettingsService, fileService, stateManager }; } @@ -934,14 +976,20 @@ function withoutUndefinedProperties(metadata: IAgentChatMetadata): Record>[number] { +function sdkSession(sessionId: string, cwd?: string, options?: ITestCopilotSessionOptions): TestCopilotSessionMetadata { return { sessionId, startTime: new Date(1000), - modifiedTime: new Date(2000), + modifiedTime: options?.modifiedTime ?? new Date(2000), summary: `SDK ${sessionId}`, isRemote: false, - ...(cwd ? { context: { workingDirectory: cwd } } : {}), + ...(cwd ? { + context: { + workingDirectory: cwd, + ...(options?.repository !== undefined ? { repository: options.repository } : {}), + } + } : {}), + ...(options?.clientName !== undefined ? { clientName: options.clientName } : {}), }; } @@ -4863,14 +4911,9 @@ suite('CopilotAgent', () => { const sessionId = 'disabled-during-migration-event'; const listStarted = new DeferredPromise(); const releaseList = new DeferredPromise(); - class GatedListClient extends TestCopilotClient { - override async listSessions(): ReturnType { - listStarted.complete(); - await releaseList.p; - return super.listSessions(); - } - } - const client = new GatedListClient([sdkSession(sessionId, workingDirectory)]); + const client = new TestCopilotClient([sdkSession(sessionId, workingDirectory)]); + client.sessionListStarted = listStarted; + client.sessionListGate = releaseList.p; await writeExtensionHostMarker(userHome, sessionId); const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client, @@ -5063,11 +5106,15 @@ suite('CopilotAgent', () => { suite('external chat discovery', () => { - test('surfaces an SDK session created by another Copilot client as external', async () => { + test('surfaces a standalone Copilot CLI SDK session as external', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/external-discovery-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/external-discovery-cwd-`); const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession('external-cli', workingDirectory)]); + const client = new TestCopilotClient([sdkSession('external-cli', workingDirectory, { + clientName: 'github/cli', + repository: 'owner/repository', + modifiedTime: new Date(), + })]); // Migration stays off: external discovery must not depend on it. const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); try { @@ -5085,7 +5132,11 @@ suite('CopilotAgent', () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/external-origin-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/external-origin-cwd-`); const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([sdkSession('other-origin', workingDirectory)]); + const client = new TestCopilotClient([sdkSession('other-origin', workingDirectory, { + clientName: 'github/autopilot', + repository: 'owner/repository', + modifiedTime: new Date(), + })]); const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); try { // The GitHub Copilot app writes the same sidecar with `origin: 'other'`. @@ -5101,6 +5152,80 @@ suite('CopilotAgent', () => { } }); + test('does not surface SDK sessions with an unknown or missing client name', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/unsupported-client-discovery-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/unsupported-client-discovery-cwd-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([ + sdkSession('unknown-client', workingDirectory, { clientName: 'other/client', repository: 'owner/repository', modifiedTime: new Date() }), + sdkSession('missing-client', workingDirectory, { repository: 'owner/repository', modifiedTime: new Date() }), + ]); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + assert.deepStrictEqual(await collectDiscoveredChats(agent), []); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('surfaces only sessions modified within the seven-day boundary', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/age-boundary-discovery-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/age-boundary-discovery-cwd-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const now = Date.UTC(2026, 7, 17, 12); + const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000; + const client = new TestCopilotClient([ + sdkSession('at-boundary', workingDirectory, { clientName: 'github/cli', repository: 'owner/repository', modifiedTime: new Date(sevenDaysAgo) }), + sdkSession('outside-boundary', workingDirectory, { clientName: 'github/cli', repository: 'owner/repository', modifiedTime: new Date(sevenDaysAgo - 1) }), + ]); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome, now: () => now }); + try { + assert.deepStrictEqual(await collectDiscoveredChats(agent), [ + { id: 'at-boundary', external: true, adoptable: false }, + ]); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('does not surface a session with missing repository metadata', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/missing-repository-discovery-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/missing-repository-discovery-cwd-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([ + sdkSession('missing-repository', workingDirectory, { clientName: 'github/cli', modifiedTime: new Date() }), + ]); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + assert.deepStrictEqual(await collectDiscoveredChats(agent), []); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + + test('does not surface a repository-less session', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/repository-less-discovery-home-`)); + const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/repository-less-discovery-cwd-`); + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([ + sdkSession('repository-less', workingDirectory, { clientName: 'github/autopilot', repository: '', modifiedTime: new Date() }), + ]); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); + try { + assert.deepStrictEqual(await collectDiscoveredChats(agent), []); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await fs.rm(workingDirectory, { recursive: true, force: true }); + await disposeAgent(agent); + } + }); + test('keeps a legacy extension-host chat internal and adoptable', async () => { const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/adoptable-discovery-home-`)); const workingDirectory = await fs.mkdtemp(`${os.tmpdir()}/adoptable-discovery-cwd-`); @@ -5158,8 +5283,8 @@ suite('CopilotAgent', () => { } const sessionDataService = disposables.add(new FailingSessionDataService()); const client = new TestCopilotClient([ - sdkSession('corrupt', workingDirectory), - sdkSession('healthy', workingDirectory), + sdkSession('corrupt', workingDirectory, { clientName: 'github/cli', repository: 'owner/repository', modifiedTime: new Date() }), + sdkSession('healthy', workingDirectory, { clientName: 'github/cli', repository: 'owner/repository', modifiedTime: new Date() }), ]); const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, userHome }); try { diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 4ad91ac70a785..f48ac388874a2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -137,6 +137,8 @@ The **only** per-provider difference is the storage key: local uses the fixed `l Provider-native sessions discovered outside the Agent Host carry `_meta.external`. `chat.agentSessions.showExternal` controls whether the catalog publishes none, all, the last 24 hours, or the last 7 days (the default). Configuration changes publish or unpublish matching summaries immediately, including restored sessions, while retaining live Agent Host state so a later settings change can surface them again. The state manager distinguishes a summary retained as the diff baseline from one actually published through `root/sessionAdded`; restoring a filtered session records the former without implying the latter, and hidden summary changes advance that baseline without emitting root notifications. +Copilot discovery includes external SDK sessions only when their persisted `clientName` is exactly `github/cli` or `github/autopilot`, their persisted context includes non-empty repository metadata, and they were modified within the last seven days. Unknown and missing client names, repository-less sessions, and older sessions are excluded. `clientName` identifies the runtime client that created or last resumed the session, not immutable creator provenance. + Both the regular VS Code agent sessions list and the Agents Window Sessions list expose this setting as an `External` submenu directly below their provider filters. The checked option follows the effective configuration value, and selecting an option writes the user setting. The first external session opened in the Agents window shows a profile-scoped, one-time banner at the top of the chat. Its picker deliberately starts on a disabled placeholder rather than the effective seven-day default. Saving updates the setting; saving or closing records dismissal in profile storage. A setting that excludes the open session requires confirmation before the update is applied.