Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { INTEGRATION_ID } from '../../../../platform/endpoint/common/licenseAgre
import { INativeEnvService } from '../../../../platform/env/common/envService';
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
import { RelativePattern } from '../../../../platform/filesystem/common/fileTypes';
import { FileType, RelativePattern } from '../../../../platform/filesystem/common/fileTypes';
import { ILogService } from '../../../../platform/log/common/logService';
import { deriveCopilotCliOTelEnv } from '../../../../platform/otel/common/agentOTelEnv';
import { IOTelService } from '../../../../platform/otel/common/otelService';
Expand Down Expand Up @@ -55,6 +55,10 @@ import { ICopilotCLIMCPHandler, McpServerMappings, remapCustomAgentTools } from


const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile';
const AGENT_HOST_ENABLED_SETTING_ID = 'chat.agentHost.enabled';
const AGENT_HOST_DEFAULT_SESSIONS_PROVIDER_SETTING_ID = 'chat.agentHost.defaultSessionsProvider';
const DEFAULT_TO_COPILOT_HARNESS_SETTING_ID = 'chat.defaultToCopilotHarness';
const AGENT_HOST_COPILOT_CLIENT_NAME = 'vscode-agent-host';
export const COPILOT_CLI_CHAT_PANEL_SYSTEM_MESSAGE = 'You are an AI assistant using Copilot CLI runtime in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot CLI runtime in VS Code.';

type SDKPackage = Awaited<ReturnType<ICopilotCLISDK['getPackage']>>;
Expand Down Expand Up @@ -193,7 +197,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
void this.createCustomAgentLookup();
}
}));
this.monitorSessionFiles();
if (this.shouldMonitorSessionFiles()) {
this.monitorSessionFiles();
}
this._sessionManager = new Lazy<Promise<internal.LocalSessionManager>>(async () => {
try {
const sdkPackage = await this.getSDKPackage();
Expand Down Expand Up @@ -231,6 +237,17 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
this._sessionTracker = this.instantiationService.createInstance(CopilotCLISessionWorkspaceTracker);
}

private shouldMonitorSessionFiles(): boolean {
if (this.configurationService.getNonExtensionConfig<boolean>(AGENT_HOST_ENABLED_SETTING_ID) !== true) {
return true;
}

const defaultProviderSettingId = this._agentSessionsWorkspace.isAgentSessionsWorkspace
? AGENT_HOST_DEFAULT_SESSIONS_PROVIDER_SETTING_ID
: DEFAULT_TO_COPILOT_HARNESS_SETTING_ID;
return this.configurationService.getNonExtensionConfig<boolean>(defaultProviderSettingId) !== true;
}

private async getSDKPackage(): Promise<SDKPackage> {
return this.copilotCLISDK.getPackage();
}
Expand Down Expand Up @@ -398,6 +415,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
if (!metadata || token.isCancellationRequested) {
return;
}
if (metadata.clientName === AGENT_HOST_COPILOT_CLIENT_NAME) {
return;
}
await this._sessionTracker.initialize();
return await this.constructSessionItem(metadata, token);
}
Expand Down Expand Up @@ -480,19 +500,19 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
return joinPath(userDataPath, 'agentSessionData');
}

/**
* Whether the agent host owns this session — it writes a per-session SQLite DB at
* `<userDataPath>/agentSessionData/<sessionId>/session.db` and we skip those to avoid double-listing sessions both
* surfaces read from the shared `~/.copilot/session-state/` directory.
*/
private async _isOwnedByAgentHost(sessionId: string, dataDir: URI | undefined): Promise<boolean> {
private async _getAgentHostOwnedSessionIds(dataDir: URI | undefined): Promise<ReadonlySet<string>> {
if (!dataDir) {
return false;
return new Set();
}
try {
const entries = await this.fileSystem.readDirectory(dataDir);
return new Set(entries
.filter(([, type]) => (type & FileType.Directory) !== 0)
.map(([name]) => name));
} catch (error) {
this.logService.trace(`Failed to read Agent Host session data directory: ${error}`);
return new Set();
}
// Must mirror `SessionDataService._sanitizedSessionKey`.
const sanitized = sessionId.replace(/[^a-zA-Z0-9_.-]/g, '-');
const dbPath = joinPath(dataDir, sanitized, 'session.db');
return this.fileSystem.stat(dbPath).then(() => true, () => false);
}

async _getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {
Expand All @@ -505,11 +525,16 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS

// Skip sessions the agent host already lists (both surfaces share `~/.copilot/session-state/`).
const agentHostDataDir = this._getAgentHostSessionDataDir();
const sessionsWithoutAgentHostClientName = sessionMetadataList.filter(metadata => metadata.clientName !== AGENT_HOST_COPILOT_CLIENT_NAME);
const agentHostOwnedSessionIds = sessionsWithoutAgentHostClientName.length > 0
? await this._getAgentHostOwnedSessionIds(agentHostDataDir)
: new Set<string>();

// Convert SessionMetadata to ICopilotCLISession
const diskSessions: ICopilotCLISessionItem[] = coalesce(await Promise.all(
sessionMetadataList.map(async (metadata): Promise<ICopilotCLISessionItem | undefined> => {
if (await this._isOwnedByAgentHost(metadata.sessionId, agentHostDataDir)) {
const sanitizedSessionId = metadata.sessionId.replace(/[^a-zA-Z0-9_.-]/g, '-');
if (metadata.clientName === AGENT_HOST_COPILOT_CLIENT_NAME || agentHostOwnedSessionIds.has(sanitizedSessionId)) {
return;
}
const workingDirectory = metadata.context?.cwd ? URI.file(metadata.context.cwd) : undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ import { mkdir, mkdtemp, rm, writeFile as writeNodeFile } from 'node:fs/promises
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ChatContext, ChatCustomAgent, ChatParticipantToolToken, Uri } from 'vscode';
import type { ChatContext, ChatCustomAgent, ChatParticipantToolToken, Event as VSCodeEvent, FileSystemWatcher, Uri } from 'vscode';
import { CancellationToken } from 'vscode-languageserver-protocol';
import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication';
import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/common/chatDebugFileLoggerService';
import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';
import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';
import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';
import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';
import { FileType } from '../../../../../platform/filesystem/common/fileTypes';
import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService';
import { ILogService } from '../../../../../platform/log/common/logService';
import { NullMcpService } from '../../../../../platform/mcp/common/mcpService';
Expand Down Expand Up @@ -63,7 +66,40 @@ class MockLocalSession {

export class NullAgentSessionsWorkspace implements IAgentSessionsWorkspace {
_serviceBrand: undefined;
readonly isAgentSessionsWorkspace = false;
constructor(readonly isAgentSessionsWorkspace = false) { }
}

const emptyFileSystemEvent: VSCodeEvent<Uri> = () => toDisposable(() => { });

class TestFileSystemWatcher implements FileSystemWatcher {
ignoreCreateEvents = false;
ignoreChangeEvents = false;
ignoreDeleteEvents = false;
readonly onDidCreate = emptyFileSystemEvent;
readonly onDidChange = emptyFileSystemEvent;
readonly onDidDelete = emptyFileSystemEvent;
dispose(): void { }
}

class TrackingFileSystemService extends MockFileSystemService {
createFileSystemWatcherCallCount = 0;
readDirectoryCallCount = 0;

override createFileSystemWatcher(): FileSystemWatcher {
this.createFileSystemWatcherCallCount++;
return new TestFileSystemWatcher();
}

override async readDirectory(uri: URI): Promise<[string, FileType][]> {
this.readDirectoryCallCount++;
return super.readDirectory(uri);
}
}

class TestExtensionContext extends mock<IVSCodeExtensionContext>() {
constructor(public override readonly globalStorageUri: Uri) {
super();
}
}

class NullChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
Expand Down Expand Up @@ -116,6 +152,13 @@ function createCustomAgent(uri: URI, name: string): ChatCustomAgent {
};
}

interface ICreateSessionServiceOptions {
readonly configurationService?: IConfigurationService;
readonly extensionContext?: IVSCodeExtensionContext;
readonly fileSystem?: MockFileSystemService;
readonly isAgentSessionsWorkspace?: boolean;
}

const createLocalFeatureFlagServiceCreator = () => () => ({});

describe('CopilotCLISessionService', () => {
Expand All @@ -126,6 +169,8 @@ describe('CopilotCLISessionService', () => {
let manager: MockCliSdkSessionManager;
let metadataStore: MockChatSessionMetadataStore;
let promptsService: MockPromptsService;
let configurationService: IConfigurationService;
let createSessionService: (options?: ICreateSessionServiceOptions) => CopilotCLISessionService;
let tempStateHome: string | undefined;
const originalXdgStateHome = process.env.XDG_STATE_HOME;
beforeEach(async () => {
Expand Down Expand Up @@ -182,12 +227,16 @@ describe('CopilotCLISessionService', () => {
return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], undefined, logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), { _serviceBrand: undefined } as any, { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, new NullTelemetryService()));
}
} as unknown as IInstantiationService;
const configurationService = accessor.get(IConfigurationService);
configurationService = accessor.get(IConfigurationService);
const nullMcpServer = disposables.add(new NullMcpService());
const titleService = new NullCustomSessionTitleService();
metadataStore = new MockChatSessionMetadataStore();
promptsService = disposables.add(new MockPromptsService());
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), promptsService, new NullCopilotCLIModels(), new NullExperimentationService()));
createSessionService = (options = {}) => {
const serviceConfiguration = options.configurationService ?? configurationService;
return new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), options.fileSystem ?? new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, serviceConfiguration, nullMcpServer), cliAgents, workspaceService, titleService, serviceConfiguration, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(options.isAgentSessionsWorkspace), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), promptsService, new NullCopilotCLIModels(), new NullExperimentationService(), options.extensionContext);
};
service = disposables.add(createSessionService());
manager = await service.getSessionManager() as unknown as MockCliSdkSessionManager;
});

Expand All @@ -204,6 +253,38 @@ describe('CopilotCLISessionService', () => {

// --- Tests ----------------------------------------------------------------------------------

describe('session file monitoring', () => {
it('skips the watcher when Agent Host is the default for the current window', async () => {
const cases = [
{ name: 'Agents window Agent Host default', isAgentSessionsWorkspace: true, agentHostEnabled: true, agentsDefault: true, editorDefault: false, expectedWatcherCount: 0 },
{ name: 'editor window Agent Host default', isAgentSessionsWorkspace: false, agentHostEnabled: true, agentsDefault: false, editorDefault: true, expectedWatcherCount: 0 },
{ name: 'Agents window Agent Host disabled', isAgentSessionsWorkspace: true, agentHostEnabled: false, agentsDefault: true, editorDefault: false, expectedWatcherCount: 1 },
{ name: 'editor window Agent Host disabled', isAgentSessionsWorkspace: false, agentHostEnabled: false, agentsDefault: false, editorDefault: true, expectedWatcherCount: 1 },
{ name: 'Agents window editor default only', isAgentSessionsWorkspace: true, agentHostEnabled: true, agentsDefault: false, editorDefault: true, expectedWatcherCount: 1 },
{ name: 'editor window Agents default only', isAgentSessionsWorkspace: false, agentHostEnabled: true, agentsDefault: true, editorDefault: false, expectedWatcherCount: 1 },
];

const results = [];
for (const testCase of cases) {
const testConfiguration = disposables.add(new InMemoryConfigurationService(configurationService));
await Promise.all([
testConfiguration.setNonExtensionConfig('chat.agentHost.enabled', testCase.agentHostEnabled),
testConfiguration.setNonExtensionConfig('chat.agentHost.defaultSessionsProvider', testCase.agentsDefault),
testConfiguration.setNonExtensionConfig('chat.defaultToCopilotHarness', testCase.editorDefault),
]);
const fileSystem = new TrackingFileSystemService();
disposables.add(createSessionService({
configurationService: testConfiguration,
fileSystem,
isAgentSessionsWorkspace: testCase.isAgentSessionsWorkspace,
}));
results.push({ name: testCase.name, watcherCount: fileSystem.createFileSystemWatcherCallCount });
}

expect(results).toEqual(cases.map(testCase => ({ name: testCase.name, watcherCount: testCase.expectedWatcherCount })));
});
});

describe('CopilotCLISessionService.getChatHistory', () => {
it('refreshes cached custom agent mode instructions when custom agents change', async () => {
const agentUri = URI.file('/workspace/.github/agents/review.agent.md');
Expand Down Expand Up @@ -653,6 +734,47 @@ describe('CopilotCLISessionService', () => {
});

describe('CopilotCLISessionService.getAllSessions', () => {
it('filters Agent Host sessions without per-session stats', async () => {
const fileSystem = new TrackingFileSystemService();
const globalStorageUri = URI.file('/user-data/User/globalStorage/github.copilot-chat');
fileSystem.mockDirectory(URI.file('/user-data/agentSessionData'), [
['legacy-owned', FileType.Directory],
['not-a-session', FileType.File],
]);
const filteredService = disposables.add(createSessionService({
extensionContext: new TestExtensionContext(globalStorageUri),
fileSystem,
}));
const filteredManager = await filteredService.getSessionManager() as unknown as MockCliSdkSessionManager;
const sessions = [
{ id: 'agent-host', clientName: 'vscode-agent-host' },
{ id: 'legacy:owned', clientName: 'vscode' },
{ id: 'extension-host', clientName: 'vscode' },
{ id: 'standalone', clientName: 'copilot-cli' },
];
for (const { id, clientName } of sessions) {
const sdkSession = new MockCliSdkSession(id, new Date(0));
sdkSession.clientName = clientName;
sdkSession.summary = id;
filteredManager.sessions.set(id, sdkSession);
}

const result = await filteredService.getAllSessions(CancellationToken.None);
const targetedAgentHostItem = await filteredService.getSessionItem('agent-host', CancellationToken.None);

expect({
sessionIds: result.map(item => item.id),
targetedAgentHostItem,
readDirectoryCallCount: fileSystem.readDirectoryCallCount,
statCallCount: fileSystem.getStatCallCount(),
}).toEqual({
sessionIds: ['extension-host', 'standalone'],
targetedAgentHostItem: undefined,
readDirectoryCallCount: 1,
statCallCount: 0,
});
});

it('will not list created sessions', async () => {
const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
disposables.add(session);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class MockCliSdkSession {
public events: {}[] = [];
public title: string | undefined;
public name: string | undefined;
public clientName: string | undefined;
public readonly renameSession = async (name: string): Promise<void> => {
this.title = name;
this.name = name;
Expand Down Expand Up @@ -90,11 +91,11 @@ export class MockCliSdkSessionManager {
return Promise.resolve(undefined);
}
listSessions() {
return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime, modifiedTime: s.startTime, summary: s.summary, name: s.name })));
return Promise.resolve(Array.from(this.sessions.values()).map(s => ({ sessionId: s.sessionId, startTime: s.startTime, modifiedTime: s.startTime, summary: s.summary, name: s.name, clientName: s.clientName })));
}
getSessionMetadata({ sessionId }: { sessionId: string }) {
const session = this.sessions.get(sessionId);
return Promise.resolve(session ? { sessionId: session.sessionId, startTime: session.startTime, modifiedTime: session.startTime, summary: session.summary, name: session.name, isRemote: false } : undefined);
return Promise.resolve(session ? { sessionId: session.sessionId, startTime: session.startTime, modifiedTime: session.startTime, summary: session.summary, name: session.name, clientName: session.clientName, isRemote: false } : undefined);
}
deleteSession(id: string) { this.sessions.delete(id); return Promise.resolve(); }
closeSession(_id: string) { return Promise.resolve(); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type ReasoningEffort = NonNullable<SessionConfig['reasoningEffort']>;

const ContextTiers = ['default', 'long_context'] as const;
type ContextTier = NonNullable<SessionConfig['contextTier']>;
const AGENT_HOST_COPILOT_CLIENT_NAME = 'vscode-agent-host';

type UserInputHandler = NonNullable<SessionConfig['onUserInputRequest']>;
type UserInputRequest = Parameters<UserInputHandler>[0];
Expand Down Expand Up @@ -580,7 +581,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
}
return {
...byok,
clientName: 'vscode',
clientName: AGENT_HOST_COPILOT_CLIENT_NAME,
enableMcpApps: true,
enableFileHooks: true,
enableConfigDiscovery: true,
Expand Down
Loading
Loading