diff --git a/package-lock.json b/package-lock.json index 434c1feebf..5dd8b924a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.2.4", + "@vscode/copilot-api": "^0.2.5", "@vscode/extension-telemetry": "^1.2.0", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.5", @@ -6429,9 +6429,9 @@ } }, "node_modules/@vscode/copilot-api": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.2.4.tgz", - "integrity": "sha512-JnzS46PDhsjrVsgYbN2tcOOtnIR3Brb//O/jweC7zyN+/54+8KPANanXPUgn32dwBCRbihjjeI5eHW1VI/j7yg==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@vscode/copilot-api/-/copilot-api-0.2.5.tgz", + "integrity": "sha512-FITunwQI7JNXOFHikgMt6y2TKEjro14CCJbYjieLwvXkv+3t6tDxI0SEU+W4z0VxMMp4g3uCOqk8+WZa1LQaBw==", "license": "SEE LICENSE" }, "node_modules/@vscode/debugadapter": { diff --git a/package.json b/package.json index d6129c6852..fcc517cf4a 100644 --- a/package.json +++ b/package.json @@ -2596,6 +2596,11 @@ "default": true, "description": "%github.copilot.config.customInstructionsInSystemMessage%" }, + "github.copilot.chat.customAgents.showOrganizationAndEnterpriseAgents": { + "type": "boolean", + "default": true, + "description": "%github.copilot.config.customAgents.showOrganizationAndEnterpriseAgents%" + }, "github.copilot.chat.agent.currentEditorContext.enabled": { "type": "boolean", "default": true, @@ -5318,7 +5323,7 @@ "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", "@sinclair/typebox": "^0.34.41", - "@vscode/copilot-api": "^0.2.4", + "@vscode/copilot-api": "^0.2.5", "@vscode/extension-telemetry": "^1.2.0", "@vscode/l10n": "^0.0.18", "@vscode/prompt-tsx": "^0.4.0-alpha.5", diff --git a/package.nls.json b/package.nls.json index 07bfcf70e3..f0446baecb 100644 --- a/package.nls.json +++ b/package.nls.json @@ -344,6 +344,7 @@ "copilot.tools.createDirectory.description": "Create new directories in your workspace", "github.copilot.config.agent.currentEditorContext.enabled": "When enabled, Copilot will include the name of the current active editor in the context for agent mode.", "github.copilot.config.customInstructionsInSystemMessage": "When enabled, custom instructions and mode instructions will be appended to the system message instead of a user message.", + "github.copilot.config.customAgents.showOrganizationAndEnterpriseAgents": "Enable custom agents from GitHub Enterprise and Organizations. When disabled, custom agents from your organization or enterprise will not be available in Copilot.", "copilot.toolSet.editing.description": "Edit files in your workspace", "copilot.toolSet.read.description": "Read files in your workspace", "copilot.toolSet.search.description": "Search files in your workspace", diff --git a/src/extension/agents/vscode-node/organizationAndEnterpriseAgentContrib.ts b/src/extension/agents/vscode-node/organizationAndEnterpriseAgentContrib.ts new file mode 100644 index 0000000000..537c7816a3 --- /dev/null +++ b/src/extension/agents/vscode-node/organizationAndEnterpriseAgentContrib.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { IExtensionContribution } from '../../common/contributions'; +import { OrganizationAndEnterpriseAgentProvider } from './organizationAndEnterpriseAgentProvider'; + +export class OrganizationAndEnterpriseAgentContribution extends Disposable implements IExtensionContribution { + readonly id = 'OrganizationAndEnterpriseAgents'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + if ('registerCustomAgentsProvider' in vscode.chat) { + // Only register the provider if the setting is enabled + if (configurationService.getConfig(ConfigKey.ShowOrganizationAndEnterpriseAgents)) { + const provider = instantiationService.createInstance(OrganizationAndEnterpriseAgentProvider); + this._register(vscode.chat.registerCustomAgentsProvider(provider)); + } + } + } +} diff --git a/src/extension/agents/vscode-node/organizationAndEnterpriseAgentProvider.ts b/src/extension/agents/vscode-node/organizationAndEnterpriseAgentProvider.ts new file mode 100644 index 0000000000..3120a1c87d --- /dev/null +++ b/src/extension/agents/vscode-node/organizationAndEnterpriseAgentProvider.ts @@ -0,0 +1,300 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import YAML from 'yaml'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { FileType } from '../../../platform/filesystem/common/fileTypes'; +import { IGitService } from '../../../platform/git/common/gitService'; +import { CustomAgentDetails, CustomAgentListOptions, IOctoKitService } from '../../../platform/github/common/githubService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { getRepoId } from '../../chatSessions/vscode/copilotCodingAgentUtils'; + +const AgentFileExtension = '.agent.md'; + +export class OrganizationAndEnterpriseAgentProvider extends Disposable implements vscode.CustomAgentsProvider { + + private readonly _onDidChangeCustomAgents = this._register(new vscode.EventEmitter()); + readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; + + private isFetching = false; + + constructor( + @IOctoKitService private readonly octoKitService: IOctoKitService, + @ILogService private readonly logService: ILogService, + @IGitService private readonly gitService: IGitService, + @IVSCodeExtensionContext readonly extensionContext: IVSCodeExtensionContext, + @IFileSystemService private readonly fileSystem: IFileSystemService, + ) { + super(); + } + + private getCacheDir(): vscode.Uri | undefined { + if (!this.extensionContext.storageUri) { + return; + } + return vscode.Uri.joinPath(this.extensionContext.storageUri, 'githubAgentsCache'); + } + + async provideCustomAgents( + options: vscode.CustomAgentQueryOptions, + _token: vscode.CancellationToken + ): Promise { + try { + // Get repository information from the active git repository + const repoId = await getRepoId(this.gitService); + if (!repoId) { + this.logService.trace('[OrganizationAndEnterpriseAgentProvider] No active repository found'); + return []; + } + + const repoOwner = repoId.org; + const repoName = repoId.repo; + + // Read from cache first + const cachedAgents = await this.readFromCache(repoOwner, repoName); + + // Trigger async fetch to update cache + this.fetchAndUpdateCache(repoOwner, repoName, options).catch(error => { + this.logService.error(`[OrganizationAndEnterpriseAgentProvider] Error in background fetch: ${error}`); + }); + + return cachedAgents; + } catch (error) { + this.logService.error(`[OrganizationAndEnterpriseAgentProvider] Error in provideCustomAgents: ${error}`); + return []; + } + } + + private async readFromCache( + repoOwner: string, + repoName: string, + ): Promise { + try { + const cacheDir = this.getCacheDir(); + if (!cacheDir) { + this.logService.trace('[OrganizationAndEnterpriseAgentProvider] No workspace open, cannot use cache'); + return []; + } + + const cacheContents = await this.readCacheContents(cacheDir); + if (cacheContents.size === 0) { + this.logService.trace(`[OrganizationAndEnterpriseAgentProvider] No cache found for ${repoOwner}/${repoName}`); + return []; + } + + const agents: vscode.CustomAgentResource[] = []; + + for (const [filename, text] of cacheContents) { + // Parse metadata from the file (name and description) + const metadata = this.parseAgentMetadata(text, filename); + if (metadata) { + const fileUri = vscode.Uri.joinPath(cacheDir, filename); + agents.push({ + name: metadata.name, + description: metadata.description, + uri: fileUri, + }); + } + } + + this.logService.trace(`[OrganizationAndEnterpriseAgentProvider] Loaded ${agents.length} agents/prompts from cache for ${repoOwner}/${repoName}`); + return agents; + } catch (error) { + this.logService.error(`[OrganizationAndEnterpriseAgentProvider] Error reading from cache: ${error}`); + return []; + } + } + + private async fetchAndUpdateCache( + repoOwner: string, + repoName: string, + options: vscode.CustomAgentQueryOptions + ): Promise { + // Prevent concurrent fetches + if (this.isFetching) { + this.logService.trace('[OrganizationAndEnterpriseAgentProvider] Fetch already in progress, skipping'); + return; + } + + this.isFetching = true; + try { + this.logService.trace(`[OrganizationAndEnterpriseAgentProvider] Fetching custom agents for ${repoOwner}/${repoName}`); + + // Convert VS Code API options to internal options + const internalOptions = options ? { + target: options.target, + includeSources: ['org', 'enterprise'] // don't include 'repo' to avoid redundancy + } satisfies CustomAgentListOptions : undefined; + + const agents = await this.octoKitService.getCustomAgents(repoOwner, repoName, internalOptions); + const cacheDir = this.getCacheDir(); + if (!cacheDir) { + this.logService.trace('[OrganizationAndEnterpriseAgentProvider] No workspace open, cannot use cache'); + return; + } + + // Ensure cache directory exists + try { + await this.fileSystem.stat(cacheDir); + } catch (error) { + // Directory doesn't exist, create it + await this.fileSystem.createDirectory(cacheDir); + } + + // Read existing cache contents before updating + const existingContents = await this.readCacheContents(cacheDir); + + // Generate new cache contents + const newContents = new Map(); + for (const agent of agents) { + const filename = this.sanitizeFilename(agent.name) + AgentFileExtension; + + // Fetch full agent details including prompt content + const agentDetails = await this.octoKitService.getCustomAgentDetails( + agent.repo_owner, + agent.repo_name, + agent.name, + agent.version + ); + + // Generate agent markdown file content + if (agentDetails) { + const content = this.generateAgentMarkdown(agentDetails); + newContents.set(filename, content); + } + } + + // Compare contents to detect changes + const hasChanges = this.hasContentChanged(existingContents, newContents); + + if (!hasChanges) { + this.logService.trace(`[OrganizationAndEnterpriseAgentProvider] No changes detected in cache for ${repoOwner}/${repoName}`); + return; + } + + // Clear existing cache files + const existingFiles = await this.fileSystem.readDirectory(cacheDir); + for (const [filename, fileType] of existingFiles) { + if (fileType === FileType.File && filename.endsWith(AgentFileExtension)) { + await this.fileSystem.delete(vscode.Uri.joinPath(cacheDir, filename)); + } + } + + // Write new cache files + for (const [filename, content] of newContents) { + const fileUri = vscode.Uri.joinPath(cacheDir, filename); + await this.fileSystem.writeFile(fileUri, new TextEncoder().encode(content)); + } + + this.logService.trace(`[OrganizationAndEnterpriseAgentProvider] Updated cache with ${agents.length} agents for ${repoOwner}/${repoName}`); + + // Fire event to notify consumers that agents have changed + this._onDidChangeCustomAgents.fire(); + } finally { + this.isFetching = false; + } + } + + private async readCacheContents(cacheDir: vscode.Uri): Promise> { + const contents = new Map(); + try { + const files = await this.fileSystem.readDirectory(cacheDir); + for (const [filename, fileType] of files) { + if (fileType === FileType.File && filename.endsWith(AgentFileExtension)) { + const fileUri = vscode.Uri.joinPath(cacheDir, filename); + const content = await this.fileSystem.readFile(fileUri); + const text = new TextDecoder().decode(content); + contents.set(filename, text); + } + } + } catch { + // Directory might not exist yet or other errors + } + return contents; + } + + private hasContentChanged(oldContents: Map, newContents: Map): boolean { + // Check if the set of files changed + if (oldContents.size !== newContents.size) { + return true; + } + + // Check if any file content changed + for (const [filename, newContent] of newContents) { + const oldContent = oldContents.get(filename); + if (oldContent !== newContent) { + return true; + } + } + + // Check if any old files are missing in new contents + for (const filename of oldContents.keys()) { + if (!newContents.has(filename)) { + return true; + } + } + + return false; + } + + private generateAgentMarkdown(agent: CustomAgentDetails): string { + const frontmatterObj: Record = {}; + + if (agent.display_name) { + frontmatterObj.name = agent.display_name; + } + if (agent.description) { + // Escape newlines in description to keep it on a single line + frontmatterObj.description = agent.description.replace(/\n/g, '\\n'); + } + if (agent.tools && agent.tools.length > 0 && agent.tools[0] !== '*') { + frontmatterObj.tools = agent.tools; + } + if (agent.argument_hint) { + frontmatterObj['argument-hint'] = agent.argument_hint; + } + if (agent.target) { + frontmatterObj.target = agent.target; + } + + const frontmatter = YAML.stringify(frontmatterObj, { lineWidth: 0 }).trim(); + const body = agent.prompt ?? ''; + + return `---\n${frontmatter}\n---\n${body}\n`; + } + + private parseAgentMetadata(content: string, filename: string): { name: string; description: string } | null { + try { + // Extract name from filename (e.g., "example.agent.md" -> "example") + const name = filename.replace(AgentFileExtension, ''); + let description = ''; + + // Look for frontmatter (YAML between --- markers) and extract description + const lines = content.split('\n'); + if (lines[0]?.trim() === '---') { + const endIndex = lines.findIndex((line, i) => i > 0 && line.trim() === '---'); + if (endIndex > 0) { + const frontmatter = lines.slice(1, endIndex).join('\n'); + const descMatch = frontmatter.match(/description:\s*(.+)/); + if (descMatch) { + description = descMatch[1].trim(); + } + } + } + + return { name, description }; + } catch (error) { + this.logService.error(`[OrganizationAndEnterpriseAgentProvider] Error parsing agent metadata: ${error}`); + return null; + } + } + + private sanitizeFilename(name: string): string { + return name.replace(/[^a-z0-9_-]/gi, '_').toLowerCase(); + } +} diff --git a/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts b/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts new file mode 100644 index 0000000000..b9633c6c84 --- /dev/null +++ b/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts @@ -0,0 +1,965 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'chai'; +import { afterEach, beforeEach, suite, test } from 'vitest'; +import * as vscode from 'vscode'; +import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService'; +import { FileType } from '../../../../platform/filesystem/common/fileTypes'; +import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; +import { GithubRepoId, IGitService, RepoContext } from '../../../../platform/git/common/gitService'; +import { CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, IOctoKitService } from '../../../../platform/github/common/githubService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { Event } from '../../../../util/vs/base/common/event'; +import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { constObservable, observableValue } from '../../../../util/vs/base/common/observable'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { createExtensionUnitTestingServices } from '../../../test/node/services'; +import { OrganizationAndEnterpriseAgentProvider } from '../organizationAndEnterpriseAgentProvider'; + +/** + * Mock implementation of IGitService for testing + */ +class MockGitService implements IGitService { + _serviceBrand: undefined; + isInitialized = true; + activeRepository = observableValue(this, undefined); + onDidOpenRepository = Event.None; + onDidCloseRepository = Event.None; + onDidFinishInitialization = Event.None; + + get repositories(): RepoContext[] { + const repo = this.activeRepository.get(); + return repo ? [repo] : []; + } + + setActiveRepository(repoId: GithubRepoId | undefined) { + if (repoId) { + this.activeRepository.set({ + rootUri: URI.file('/test/repo'), + headBranchName: undefined, + headCommitHash: undefined, + upstreamBranchName: undefined, + upstreamRemote: undefined, + isRebasing: false, + remoteFetchUrls: [`https://github.com/${repoId.org}/${repoId.repo}.git`], + remotes: [], + changes: undefined, + headBranchNameObs: constObservable(undefined), + headCommitHashObs: constObservable(undefined), + upstreamBranchNameObs: constObservable(undefined), + upstreamRemoteObs: constObservable(undefined), + isRebasingObs: constObservable(false), + isIgnored: async () => false, + }, undefined); + } else { + this.activeRepository.set(undefined, undefined); + } + } + + async getRepository(uri: URI): Promise { + return undefined; + } + + async getRepositoryFetchUrls(uri: URI): Promise | undefined> { + return undefined; + } + + async initialize(): Promise { } + async add(uri: URI, paths: string[]): Promise { } + async log(uri: URI, options?: any): Promise { + return []; + } + async diffBetween(uri: URI, ref1: string, ref2: string): Promise { + return []; + } + async diffWith(uri: URI, ref: string): Promise { + return []; + } + async diffIndexWithHEADShortStats(uri: URI): Promise { + return undefined; + } + async fetch(uri: URI, remote?: string, ref?: string, depth?: number): Promise { } + async getMergeBase(uri: URI, ref1: string, ref2: string): Promise { + return undefined; + } + async createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string }): Promise { + return undefined; + } + async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise { } + async migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise { } + + dispose() { } +} + +/** + * Mock implementation of IOctoKitService for testing + */ +class MockOctoKitService implements IOctoKitService { + _serviceBrand: undefined; + + private customAgents: CustomAgentListItem[] = []; + private agentDetails: Map = new Map(); + + getCurrentAuthedUser = async () => ({ login: 'testuser', name: 'Test User', avatar_url: '' }); + getCopilotPullRequestsForUser = async () => []; + getCopilotSessionsForPR = async () => []; + getSessionLogs = async () => ''; + getSessionInfo = async () => undefined; + postCopilotAgentJob = async () => undefined; + getJobByJobId = async () => undefined; + getJobBySessionId = async () => undefined; + addPullRequestComment = async () => null; + getAllOpenSessions = async () => []; + getPullRequestFromGlobalId = async () => null; + getPullRequestFiles = async () => []; + closePullRequest = async () => false; + getFileContent = async () => ''; + + async getCustomAgents(owner: string, repo: string, options?: CustomAgentListOptions): Promise { + return this.customAgents; + } + + async getCustomAgentDetails(owner: string, repo: string, agentName: string, version?: string): Promise { + return this.agentDetails.get(agentName); + } + + setCustomAgents(agents: CustomAgentListItem[]) { + this.customAgents = agents; + } + + setAgentDetails(name: string, details: CustomAgentDetails) { + this.agentDetails.set(name, details); + } + + clearAgents() { + this.customAgents = []; + this.agentDetails.clear(); + } +} + +/** + * Mock implementation of extension context for testing + */ +class MockExtensionContext { + storageUri: vscode.Uri | undefined; + + constructor(storageUri?: vscode.Uri) { + this.storageUri = storageUri; + } +} + +suite('OrganizationAndEnterpriseAgentProvider', () => { + let disposables: DisposableStore; + let mockGitService: MockGitService; + let mockOctoKitService: MockOctoKitService; + let mockFileSystem: MockFileSystemService; + let mockExtensionContext: MockExtensionContext; + let accessor: any; + let provider: OrganizationAndEnterpriseAgentProvider; + + beforeEach(() => { + disposables = new DisposableStore(); + + // Create mocks first + mockGitService = new MockGitService(); + mockOctoKitService = new MockOctoKitService(); + const storageUri = URI.file('/test/storage'); + mockExtensionContext = new MockExtensionContext(storageUri); + + // Set up testing services + const testingServiceCollection = createExtensionUnitTestingServices(disposables); + accessor = disposables.add(testingServiceCollection.createTestingAccessor()); + + mockFileSystem = accessor.get(IFileSystemService) as MockFileSystemService; + }); + + afterEach(() => { + disposables.dispose(); + mockOctoKitService.clearAgents(); + }); + + function createProvider() { + // Create provider manually with all dependencies + provider = new OrganizationAndEnterpriseAgentProvider( + mockOctoKitService, + accessor.get(ILogService), + mockGitService, + mockExtensionContext as any, + mockFileSystem + ); + disposables.add(provider); + return provider; + } + + test('returns empty array when no active repository', async () => { + mockGitService.setActiveRepository(undefined); + const provider = createProvider(); + + const agents = await provider.provideCustomAgents({}, {} as any); + + assert.deepEqual(agents, []); + }); + + test('returns empty array when no storage URI available', async () => { + mockExtensionContext.storageUri = undefined; + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const agents = await provider.provideCustomAgents({}, {} as any); + + assert.deepEqual(agents, []); + }); + + test('returns cached agents on first call', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Pre-populate cache + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + mockFileSystem.mockDirectory(cacheDir, [['test_agent.agent.md', FileType.File]]); + const agentFile = URI.joinPath(cacheDir, 'test_agent.agent.md'); + const agentContent = `--- +name: Test Agent +description: A test agent +--- +Test prompt content`; + mockFileSystem.mockFile(agentFile, agentContent); + + const agents = await provider.provideCustomAgents({}, {} as any); + + assert.equal(agents.length, 1); + assert.equal(agents[0].name, 'test_agent'); + assert.equal(agents[0].description, 'A test agent'); + }); + + test('fetches and caches agents from API', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Mock API response + const mockAgent: CustomAgentListItem = { + name: 'api_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'API Agent', + description: 'An agent from API', + tools: ['tool1'], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: 'API prompt content', + }; + mockOctoKitService.setAgentDetails('api_agent', mockDetails); + + // First call returns cached (empty) results + const agents1 = await provider.provideCustomAgents({}, {} as any); + assert.deepEqual(agents1, []); + + // Wait for background fetch to complete + await new Promise(resolve => setTimeout(resolve, 100)); + + // Second call should return newly cached agents + const agents2 = await provider.provideCustomAgents({}, {} as any); + assert.equal(agents2.length, 1); + assert.equal(agents2[0].name, 'api_agent'); + assert.equal(agents2[0].description, 'An agent from API'); + }); + + test('generates correct markdown format for agents', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockAgent: CustomAgentListItem = { + name: 'full_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Full Agent', + description: 'A fully configured agent', + tools: ['tool1', 'tool2'], + version: 'v1', + argument_hint: 'Provide context', + target: 'vscode', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: 'Detailed prompt content', + }; + mockOctoKitService.setAgentDetails('full_agent', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check cached file content + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + const agentFile = URI.joinPath(cacheDir, 'full_agent.agent.md'); + const contentBytes = await mockFileSystem.readFile(agentFile); + const content = new TextDecoder().decode(contentBytes); + + const expectedContent = `--- +name: Full Agent +description: A fully configured agent +tools: + - tool1 + - tool2 +argument-hint: Provide context +target: vscode +--- +Detailed prompt content +`; + + assert.equal(content, expectedContent); + }); + + test('sanitizes filenames correctly', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockAgent: CustomAgentListItem = { + name: 'Agent With Spaces!@#', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Agent With Spaces', + description: 'Test sanitization', + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: 'Prompt content', + }; + mockOctoKitService.setAgentDetails('Agent With Spaces!@#', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Check that file was created with sanitized name + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + const agentFile = URI.joinPath(cacheDir, 'agent_with_spaces___.agent.md'); + try { + const contentBytes = await mockFileSystem.readFile(agentFile); + const content = new TextDecoder().decode(contentBytes); + assert.ok(content, 'Sanitized file should exist'); + } catch (error) { + assert.fail('Sanitized file should exist'); + } + }); + + test('fires change event when cache is updated', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockAgent: CustomAgentListItem = { + name: 'changing_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Changing Agent', + description: 'Will change', + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: 'Initial prompt', + }; + mockOctoKitService.setAgentDetails('changing_agent', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + let eventFired = false; + provider.onDidChangeCustomAgents(() => { + eventFired = true; + }); + + // Update the agent details + mockDetails.prompt = 'Updated prompt'; + mockOctoKitService.setAgentDetails('changing_agent', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + assert.equal(eventFired, true); + }); + + test('handles API errors gracefully', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Make the API throw an error + mockOctoKitService.getCustomAgents = async () => { + throw new Error('API Error'); + }; + + // Should not throw, should return empty array + const agents = await provider.provideCustomAgents({}, {} as any); + assert.deepEqual(agents, []); + }); + + test('passes query options to API correctly', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + let capturedOptions: CustomAgentListOptions | undefined; + mockOctoKitService.getCustomAgents = async (owner: string, repo: string, options?: CustomAgentListOptions) => { + capturedOptions = options; + return []; + }; + + const queryOptions: vscode.CustomAgentQueryOptions = { + target: 'vscode' as any, + }; + + await provider.provideCustomAgents(queryOptions, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + assert.ok(capturedOptions); + assert.equal(capturedOptions.target, 'vscode'); + assert.deepEqual(capturedOptions.includeSources, ['org', 'enterprise']); + }); + + test('prevents concurrent fetches when called multiple times rapidly', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + let apiCallCount = 0; + mockOctoKitService.getCustomAgents = async () => { + apiCallCount++; + // Simulate slow API call + await new Promise(resolve => setTimeout(resolve, 50)); + return []; + }; + + // Make multiple concurrent calls + const promise1 = provider.provideCustomAgents({}, {} as any); + const promise2 = provider.provideCustomAgents({}, {} as any); + const promise3 = provider.provideCustomAgents({}, {} as any); + + await Promise.all([promise1, promise2, promise3]); + await new Promise(resolve => setTimeout(resolve, 100)); + + // API should only be called once due to isFetching guard + assert.equal(apiCallCount, 1); + }); + + test('handles partial agent detail fetch failures gracefully', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const agents: CustomAgentListItem[] = [ + { + name: 'agent1', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Agent 1', + description: 'First agent', + tools: [], + version: 'v1', + }, + { + name: 'agent2', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Agent 2', + description: 'Second agent', + tools: [], + version: 'v1', + }, + ]; + mockOctoKitService.setCustomAgents(agents); + + // Set details for only the first agent (second will fail) + mockOctoKitService.setAgentDetails('agent1', { + ...agents[0], + prompt: 'Agent 1 prompt', + }); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should cache only the successful agent + const cachedAgents = await provider.provideCustomAgents({}, {} as any); + assert.equal(cachedAgents.length, 1); + assert.equal(cachedAgents[0].name, 'agent1'); + }); + + test('detects when new agents are added to API', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Initial setup with one agent + const initialAgent: CustomAgentListItem = { + name: 'initial_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Initial Agent', + description: 'First agent', + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([initialAgent]); + mockOctoKitService.setAgentDetails('initial_agent', { + ...initialAgent, + prompt: 'Initial prompt', + }); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + let changeEventFired = false; + provider.onDidChangeCustomAgents(() => { + changeEventFired = true; + }); + + // Add a new agent + const newAgent: CustomAgentListItem = { + name: 'new_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'New Agent', + description: 'Newly added agent', + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([initialAgent, newAgent]); + mockOctoKitService.setAgentDetails('new_agent', { + ...newAgent, + prompt: 'New prompt', + }); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + assert.equal(changeEventFired, true); + const agents = await provider.provideCustomAgents({}, {} as any); + assert.equal(agents.length, 2); + }); + + test('detects when agents are removed from API', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Initial setup with two agents + const agents: CustomAgentListItem[] = [ + { + name: 'agent1', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Agent 1', + description: 'First agent', + tools: [], + version: 'v1', + }, + { + name: 'agent2', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Agent 2', + description: 'Second agent', + tools: [], + version: 'v1', + }, + ]; + mockOctoKitService.setCustomAgents(agents); + mockOctoKitService.setAgentDetails('agent1', { ...agents[0], prompt: 'Prompt 1' }); + mockOctoKitService.setAgentDetails('agent2', { ...agents[1], prompt: 'Prompt 2' }); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + let changeEventFired = false; + provider.onDidChangeCustomAgents(() => { + changeEventFired = true; + }); + + // Remove one agent + mockOctoKitService.setCustomAgents([agents[0]]); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + assert.equal(changeEventFired, true); + const cachedAgents = await provider.provideCustomAgents({}, {} as any); + assert.equal(cachedAgents.length, 1); + assert.equal(cachedAgents[0].name, 'agent1'); + }); + + test('does not fire change event when content is identical', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockAgent: CustomAgentListItem = { + name: 'stable_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Stable Agent', + description: 'Unchanging agent', + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + mockOctoKitService.setAgentDetails('stable_agent', { + ...mockAgent, + prompt: 'Stable prompt', + }); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + let changeEventCount = 0; + provider.onDidChangeCustomAgents(() => { + changeEventCount++; + }); + + // Fetch again with identical content + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + // No change event should fire + assert.equal(changeEventCount, 0); + }); + + test('handles empty agent list from API', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Setup with initial agents + const mockAgent: CustomAgentListItem = { + name: 'temporary_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Temporary Agent', + description: 'Will be removed', + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + mockOctoKitService.setAgentDetails('temporary_agent', { + ...mockAgent, + prompt: 'Temporary prompt', + }); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + let changeEventFired = false; + provider.onDidChangeCustomAgents(() => { + changeEventFired = true; + }); + + // API now returns empty array + mockOctoKitService.setCustomAgents([]); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 150)); + + assert.equal(changeEventFired, true); + const agents = await provider.provideCustomAgents({}, {} as any); + assert.equal(agents.length, 0); + }); + + test('generates markdown with only required fields', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Agent with minimal fields (no optional fields) + const mockAgent: CustomAgentListItem = { + name: 'minimal_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Minimal Agent', + description: 'Minimal description', + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: 'Minimal prompt', + }; + mockOctoKitService.setAgentDetails('minimal_agent', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + const agentFile = URI.joinPath(cacheDir, 'minimal_agent.agent.md'); + const contentBytes = await mockFileSystem.readFile(agentFile); + const content = new TextDecoder().decode(contentBytes); + + // Should have name and description, but no tools (empty array) + assert.ok(content.includes('name: Minimal Agent')); + assert.ok(content.includes('description: Minimal description')); + assert.ok(!content.includes('tools:')); + assert.ok(!content.includes('argument-hint:')); + assert.ok(!content.includes('target:')); + }); + + test('excludes tools field when array contains only wildcard', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + const mockAgent: CustomAgentListItem = { + name: 'wildcard_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Wildcard Agent', + description: 'Agent with wildcard tools', + tools: ['*'], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: 'Wildcard prompt', + }; + mockOctoKitService.setAgentDetails('wildcard_agent', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + const agentFile = URI.joinPath(cacheDir, 'wildcard_agent.agent.md'); + const contentBytes = await mockFileSystem.readFile(agentFile); + const content = new TextDecoder().decode(contentBytes); + + // Tools field should be excluded when it's just ['*'] + assert.ok(!content.includes('tools:')); + }); + + test('handles malformed frontmatter in cached files', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Pre-populate cache with mixed valid and malformed content + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + mockFileSystem.mockDirectory(cacheDir, [ + ['valid_agent.agent.md', FileType.File], + ['no_frontmatter.agent.md', FileType.File], + ]); + + const validContent = `--- +name: Valid Agent +description: A valid agent +--- +Valid prompt`; + mockFileSystem.mockFile(URI.joinPath(cacheDir, 'valid_agent.agent.md'), validContent); + + // File without frontmatter - parser extracts name from filename, description is empty + const noFrontmatterContent = `Just some content without any frontmatter`; + mockFileSystem.mockFile(URI.joinPath(cacheDir, 'no_frontmatter.agent.md'), noFrontmatterContent); + + const agents = await provider.provideCustomAgents({}, {} as any); + + // Parser is lenient - both agents are returned, one with empty description + assert.equal(agents.length, 2); + assert.equal(agents[0].name, 'valid_agent'); + assert.equal(agents[0].description, 'A valid agent'); + assert.equal(agents[1].name, 'no_frontmatter'); + assert.equal(agents[1].description, ''); + }); + + test('handles repository context changes between calls', async () => { + const provider = createProvider(); + + // First call with repo A + mockGitService.setActiveRepository(new GithubRepoId('orgA', 'repoA')); + + let capturedOwner: string | undefined; + let capturedRepo: string | undefined; + mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => { + capturedOwner = owner; + capturedRepo = repo; + return []; + }; + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + assert.equal(capturedOwner, 'orgA'); + assert.equal(capturedRepo, 'repoA'); + + // Change to repo B + mockGitService.setActiveRepository(new GithubRepoId('orgB', 'repoB')); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + // Should fetch from new repository + assert.equal(capturedOwner, 'orgB'); + assert.equal(capturedRepo, 'repoB'); + }); + + test('generates markdown with long description on single line', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Agent with a very long description that would normally be wrapped at 80 characters + const longDescription = 'Just for fun agent that teaches computer science concepts (while pretending to plot world domination).'; + const mockAgent: CustomAgentListItem = { + name: 'world_domination', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'World Domination', + description: longDescription, + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: '# World Domination Agent\n\nYou are a world-class computer scientist.', + }; + mockOctoKitService.setAgentDetails('world_domination', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + const agentFile = URI.joinPath(cacheDir, 'world_domination.agent.md'); + const contentBytes = await mockFileSystem.readFile(agentFile); + const content = new TextDecoder().decode(contentBytes); + + const expectedContent = `--- +name: World Domination +description: Just for fun agent that teaches computer science concepts (while pretending to plot world domination). +--- +# World Domination Agent + +You are a world-class computer scientist. +`; + + assert.equal(content, expectedContent); + }); + + test('generates markdown with special characters properly escaped in description', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Agent with description containing YAML special characters that need proper handling + const descriptionWithSpecialChars = "Agent with \"double quotes\", 'single quotes', colons:, and #comments in the description"; + const mockAgent: CustomAgentListItem = { + name: 'special_chars_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Special Chars Agent', + description: descriptionWithSpecialChars, + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: 'Test prompt with special characters', + }; + mockOctoKitService.setAgentDetails('special_chars_agent', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + const agentFile = URI.joinPath(cacheDir, 'special_chars_agent.agent.md'); + const contentBytes = await mockFileSystem.readFile(agentFile); + const content = new TextDecoder().decode(contentBytes); + + const expectedContent = `--- +name: Special Chars Agent +description: "Agent with \\"double quotes\\", 'single quotes', colons:, and #comments in the description" +--- +Test prompt with special characters +`; + + assert.equal(content, expectedContent); + }); + + test('generates markdown with multiline description containing newlines', async () => { + mockGitService.setActiveRepository(new GithubRepoId('testorg', 'testrepo')); + const provider = createProvider(); + + // Agent with description containing actual newline characters + const descriptionWithNewlines = 'First line of description.\nSecond line of description.\nThird line.'; + const mockAgent: CustomAgentListItem = { + name: 'multiline_agent', + repo_owner_id: 1, + repo_owner: 'testorg', + repo_id: 1, + repo_name: 'testrepo', + display_name: 'Multiline Agent', + description: descriptionWithNewlines, + tools: [], + version: 'v1', + }; + mockOctoKitService.setCustomAgents([mockAgent]); + + const mockDetails: CustomAgentDetails = { + ...mockAgent, + prompt: 'Test prompt', + }; + mockOctoKitService.setAgentDetails('multiline_agent', mockDetails); + + await provider.provideCustomAgents({}, {} as any); + await new Promise(resolve => setTimeout(resolve, 100)); + + const cacheDir = URI.joinPath(mockExtensionContext.storageUri!, 'githubAgentsCache'); + const agentFile = URI.joinPath(cacheDir, 'multiline_agent.agent.md'); + const contentBytes = await mockFileSystem.readFile(agentFile); + const content = new TextDecoder().decode(contentBytes); + + // Newlines should be escaped to keep description on a single line + const expectedContent = `--- +name: Multiline Agent +description: First line of description.\\nSecond line of description.\\nThird line. +--- +Test prompt +`; + + assert.equal(content, expectedContent); + }); +}); diff --git a/src/extension/extension/vscode-node/contributions.ts b/src/extension/extension/vscode-node/contributions.ts index ddf54ceb0a..7ca8cf3761 100644 --- a/src/extension/extension/vscode-node/contributions.ts +++ b/src/extension/extension/vscode-node/contributions.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { OrganizationAndEnterpriseAgentContribution } from '../../agents/vscode-node/organizationAndEnterpriseAgentContrib'; import { AuthenticationContrib } from '../../authentication/vscode-node/authentication.contribution'; import { BYOKContrib } from '../../byok/vscode-node/byokContribution'; import { ChatQuotaContribution } from '../../chat/vscode-node/chatQuota.contribution'; @@ -114,5 +115,6 @@ export const vscodeNodeChatContributions: IExtensionContributionFactory[] = [ asContributionFactory(BYOKContrib), asContributionFactory(McpSetupCommands), asContributionFactory(LanguageModelProxyContrib), + asContributionFactory(OrganizationAndEnterpriseAgentContribution), newWorkspaceContribution, ]; diff --git a/src/extension/vscode.proposed.chatParticipantPrivate.d.ts b/src/extension/vscode.proposed.chatParticipantPrivate.d.ts index 9f9d0a4b6a..6de8cb4779 100644 --- a/src/extension/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/extension/vscode.proposed.chatParticipantPrivate.d.ts @@ -309,4 +309,73 @@ declare module 'vscode' { } // #endregion + + // #region CustomAgentsProvider + + /** + * Represents a custom agent resource file (e.g., .agent.md or .prompt.md) available for a repository. + */ + export interface CustomAgentResource { + /** + * The unique identifier/name of the custom agent resource. + */ + readonly name: string; + + /** + * A description of what the custom agent resource does. + */ + readonly description: string; + + /** + * The URI to the agent or prompt resource file. + */ + readonly uri: Uri; + } + + /** + * Target environment for custom agents. + */ + export enum CustomAgentTarget { + GitHubCopilot = 'github-copilot', + VSCode = 'vscode', + } + + /** + * Options for querying custom agents. + */ + export interface CustomAgentQueryOptions { + /** + * Filter agents by target environment. + */ + readonly target?: CustomAgentTarget; + } + + /** + * A provider that supplies custom agent resources (from .agent.md and .prompt.md files) for repositories. + */ + export interface CustomAgentsProvider { + /** + * An optional event to signal that custom agents have changed. + */ + onDidChangeCustomAgents?: Event; + + /** + * Provide the list of custom agent resources available for a given repository. + * @param options Optional query parameters. + * @param token A cancellation token. + * @returns An array of custom agent resources or a promise that resolves to such. + */ + provideCustomAgents(options: CustomAgentQueryOptions, token: CancellationToken): ProviderResult; + } + + export namespace chat { + /** + * Register a provider for custom agents. + * @param provider The custom agents provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerCustomAgentsProvider(provider: CustomAgentsProvider): Disposable; + } + + // #endregion } diff --git a/src/platform/configuration/common/configurationService.ts b/src/platform/configuration/common/configurationService.ts index ce790bcd0f..6045a68acf 100644 --- a/src/platform/configuration/common/configurationService.ts +++ b/src/platform/configuration/common/configurationService.ts @@ -863,6 +863,9 @@ export namespace ConfigKey { export const EnableAlternateGptPrompt = defineSetting('chat.alternateGptPrompt.enabled', ConfigType.ExperimentBased, false); + /** Enable custom agents from GitHub Enterprise/Organizations */ + export const ShowOrganizationAndEnterpriseAgents = defineSetting('chat.customAgents.showOrganizationAndEnterpriseAgents', ConfigType.Simple, true); + export const CompletionsFetcher = defineSetting('chat.completionsFetcher', ConfigType.ExperimentBased, undefined); export const NextEditSuggestionsFetcher = defineSetting('chat.nesFetcher', ConfigType.ExperimentBased, undefined); diff --git a/src/platform/github/common/githubService.ts b/src/platform/github/common/githubService.ts index d0a68537d1..511412b18a 100644 --- a/src/platform/github/common/githubService.ts +++ b/src/platform/github/common/githubService.ts @@ -130,6 +130,27 @@ export interface CustomAgentListItem { description: string; tools: string[]; version: string; + argument_hint?: string; + metadata?: Record; + target?: string; + config_error?: string; + 'mcp-servers'?: { + [serverName: string]: { + type: string; + command?: string; + args?: string[]; + tools?: string[]; + env?: { [key: string]: string }; + headers?: { [key: string]: string }; + }; + }; +} + +export interface CustomAgentListOptions { + target?: 'github-copilot' | 'vscode'; + excludeInvalidConfig?: boolean; + dedupe?: boolean; + includeSources?: ('repo' | 'org' | 'enterprise')[]; } export interface CustomAgentListOptions { @@ -141,16 +162,6 @@ export interface CustomAgentListOptions { export interface CustomAgentDetails extends CustomAgentListItem { prompt: string; - 'mcp-servers'?: { - [serverName: string]: { - type: string; - command?: string; - args?: string[]; - tools?: string[]; - env?: { [key: string]: string }; - headers?: { [key: string]: string }; - }; - }; } export interface PullRequestFile { @@ -253,6 +264,16 @@ export interface IOctoKitService { */ getCustomAgents(owner: string, repo: string, options?: CustomAgentListOptions): Promise; + /** + * Gets the full configuration for a specific custom agent. + * @param owner The repository owner + * @param repo The repository name + * @param agentName The name of the custom agent + * @param version Optional git ref (branch, tag, or commit SHA) to fetch from + * @returns The complete custom agent configuration including the prompt + */ + getCustomAgentDetails(owner: string, repo: string, agentName: string, version?: string): Promise; + /** * Gets the list of files changed in a pull request. * @param owner The repository owner diff --git a/src/platform/github/common/octoKitServiceImpl.ts b/src/platform/github/common/octoKitServiceImpl.ts index 4231c65bdf..1aec40de20 100644 --- a/src/platform/github/common/octoKitServiceImpl.ts +++ b/src/platform/github/common/octoKitServiceImpl.ts @@ -9,7 +9,7 @@ import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { PullRequestComment, PullRequestSearchItem, SessionInfo } from './githubAPI'; -import { BaseOctoKitService, CustomAgentListItem, CustomAgentListOptions, ErrorResponseWithStatusCode, IOctoKitService, IOctoKitUser, JobInfo, PullRequestFile, RemoteAgentJobPayload, RemoteAgentJobResponse } from './githubService'; +import { BaseOctoKitService, CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions, ErrorResponseWithStatusCode, IOctoKitService, IOctoKitUser, JobInfo, PullRequestFile, RemoteAgentJobPayload, RemoteAgentJobResponse } from './githubService'; export class OctoKitService extends BaseOctoKitService implements IOctoKitService { declare readonly _serviceBrand: undefined; @@ -259,6 +259,36 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic } } + async getCustomAgentDetails(owner: string, repo: string, agentName: string, version?: string): Promise { + try { + const authToken = (await this._authService.getPermissiveGitHubSession({ createIfNone: true }))?.accessToken; + if (!authToken) { + throw new Error('No authentication token available'); + } + + const response = await this._capiClientService.makeRequest({ + method: 'GET', + headers: { + Authorization: `Bearer ${authToken}`, + } + }, { type: RequestType.CopilotCustomAgentsDetail, owner, repo, version, customAgentName: agentName }); + + if (!response.ok) { + if (response.status === 404) { + this._logService.trace(`Custom agent '${agentName}' not found for ${owner}/${repo}`); + return undefined; + } + throw new Error(`Failed to fetch custom agent details for ${agentName}: ${response.statusText}`); + } + + const data = await response.json() as CustomAgentDetails; + return data; + } catch (e) { + this._logService.error(e); + return undefined; + } + } + async getPullRequestFiles(owner: string, repo: string, pullNumber: number): Promise { const authToken = (await this._authService.getPermissiveGitHubSession({ createIfNone: true }))?.accessToken; if (!authToken) {