diff --git a/package.json b/package.json index 750a0e7c79..526066d81a 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "onUri", "onFileSystem:ccreq", "onFileSystem:ccsettings", - "onCustomAgentsProvider" + "onCustomAgentProvider" ], "main": "./dist/extension", "l10n": "./l10n", @@ -136,7 +136,8 @@ "languageModelThinkingPart", "chatSessionsProvider@3", "devDeviceId", - "contribEditorContentMenu" + "contribEditorContentMenu", + "chatPromptFiles" ], "contributes": { "languageModelTools": [ diff --git a/src/extension/agents/vscode-node/organizationAndEnterpriseAgentProvider.ts b/src/extension/agents/vscode-node/organizationAndEnterpriseAgentProvider.ts index f1e8b35f40..7d0cc4104c 100644 --- a/src/extension/agents/vscode-node/organizationAndEnterpriseAgentProvider.ts +++ b/src/extension/agents/vscode-node/organizationAndEnterpriseAgentProvider.ts @@ -14,13 +14,15 @@ import { Disposable } from '../../../util/vs/base/common/lifecycle'; const AgentFileExtension = '.agent.md'; -export class OrganizationAndEnterpriseAgentProvider extends Disposable implements vscode.CustomAgentsProvider { +export class OrganizationAndEnterpriseAgentProvider extends Disposable implements vscode.CustomAgentProvider { + + label: string = vscode.l10n.t('GitHub Organization'); private readonly _onDidChangeCustomAgents = this._register(new vscode.EventEmitter()); readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; private isFetching = false; - private memoryCache: vscode.CustomAgentResource[] | undefined; + private memoryCache: vscode.CustomAgentChatResource[] | undefined; constructor( @IOctoKitService private readonly octoKitService: IOctoKitService, @@ -42,9 +44,9 @@ export class OrganizationAndEnterpriseAgentProvider extends Disposable implement } async provideCustomAgents( - _options: vscode.CustomAgentQueryOptions, + _options: vscode.CustomAgentContext, _token: vscode.CancellationToken - ): Promise { + ): Promise { try { if (this.memoryCache !== undefined) { return this.memoryCache; @@ -58,7 +60,7 @@ export class OrganizationAndEnterpriseAgentProvider extends Disposable implement } } - private async readFromCache(): Promise { + private async readFromCache(): Promise { try { const cacheDir = this.getCacheDir(); if (!cacheDir) { @@ -66,7 +68,7 @@ export class OrganizationAndEnterpriseAgentProvider extends Disposable implement return []; } - const agents: vscode.CustomAgentResource[] = []; + const agents: vscode.CustomAgentChatResource[] = []; // Check if cache directory exists try { @@ -91,11 +93,7 @@ export class OrganizationAndEnterpriseAgentProvider extends Disposable implement const metadata = this.parseAgentMetadata(text, filename); if (metadata) { const fileUri = vscode.Uri.joinPath(orgDir, filename); - agents.push({ - name: metadata.name, - description: metadata.description, - uri: fileUri, - }); + agents.push(new vscode.CustomAgentChatResource(fileUri)); } } } diff --git a/src/extension/agents/vscode-node/organizationAndEnterpriseAgentContrib.ts b/src/extension/agents/vscode-node/promptFileContrib.ts similarity index 73% rename from src/extension/agents/vscode-node/organizationAndEnterpriseAgentContrib.ts rename to src/extension/agents/vscode-node/promptFileContrib.ts index 537c7816a3..f7b9762995 100644 --- a/src/extension/agents/vscode-node/organizationAndEnterpriseAgentContrib.ts +++ b/src/extension/agents/vscode-node/promptFileContrib.ts @@ -10,8 +10,8 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { IExtensionContribution } from '../../common/contributions'; import { OrganizationAndEnterpriseAgentProvider } from './organizationAndEnterpriseAgentProvider'; -export class OrganizationAndEnterpriseAgentContribution extends Disposable implements IExtensionContribution { - readonly id = 'OrganizationAndEnterpriseAgents'; +export class PromptFileContribution extends Disposable implements IExtensionContribution { + readonly id = 'PromptFiles'; constructor( @IInstantiationService instantiationService: IInstantiationService, @@ -19,11 +19,12 @@ export class OrganizationAndEnterpriseAgentContribution extends Disposable imple ) { super(); - if ('registerCustomAgentsProvider' in vscode.chat) { + // Register custom agent provider + if ('registerCustomAgentProvider' 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)); + const orgAndEnterpriseAgentProvider = instantiationService.createInstance(OrganizationAndEnterpriseAgentProvider); + this._register(vscode.chat.registerCustomAgentProvider(orgAndEnterpriseAgentProvider)); } } } diff --git a/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts b/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts index b849402404..313ca1a61d 100644 --- a/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts +++ b/src/extension/agents/vscode-node/test/organizationAndEnterpriseAgentProvider.spec.ts @@ -147,7 +147,9 @@ suite('OrganizationAndEnterpriseAgentProvider', () => { }); test('returns cached agents on first call', async () => { - const provider = createProvider(); + // Set up file system mocks BEFORE creating provider to avoid race with background fetch + // Also prevent background fetch from interfering by having no organizations + mockOctoKitService.setUserOrganizations([]); // Pre-populate cache with org folder const cacheDir = URI.joinPath(mockExtensionContext.globalStorageUri!, 'githubAgentsCache'); @@ -162,17 +164,20 @@ description: A test agent Test prompt content`; mockFileSystem.mockFile(agentFile, agentContent); + const provider = createProvider(); + + // Wait for background fetch to complete (it will return early due to no orgs) + await new Promise(resolve => setTimeout(resolve, 50)); + 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'); + const agentName = (agents[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(agentName, 'test_agent'); }); test('fetches and caches agents from API', async () => { - const provider = createProvider(); - - // Mock API response + // Mock API response BEFORE creating provider const mockAgent: CustomAgentListItem = { name: 'api_agent', repo_owner_id: 1, @@ -192,9 +197,7 @@ Test prompt content`; }; mockOctoKitService.setAgentDetails('api_agent', mockDetails); - // First call returns cached (empty) results and triggers background fetch - const agents1 = await provider.provideCustomAgents({}, {} as any); - assert.deepEqual(agents1, []); + const provider = createProvider(); // Wait for background fetch to complete await new Promise(resolve => setTimeout(resolve, 100)); @@ -202,13 +205,14 @@ Test prompt content`; // Second call should return newly cached agents from memory 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'); + const agentName2 = (agents2[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(agentName2, 'api_agent'); // Third call should also return from memory cache without file I/O const agents3 = await provider.provideCustomAgents({}, {} as any); assert.equal(agents3.length, 1); - assert.equal(agents3[0].name, 'api_agent'); + const agentName3 = (agents3[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(agentName3, 'api_agent'); }); test('generates correct markdown format for agents', async () => { @@ -359,7 +363,7 @@ Detailed prompt content return []; }; - const queryOptions: vscode.CustomAgentQueryOptions = {}; + const queryOptions: vscode.CustomAgentContext = {}; await provider.provideCustomAgents(queryOptions, {} as any); await new Promise(resolve => setTimeout(resolve, 100)); @@ -392,8 +396,6 @@ Detailed prompt content }); test('handles partial agent detail fetch failures gracefully', async () => { - const provider = createProvider(); - const agents: CustomAgentListItem[] = [ { name: 'agent1', @@ -438,20 +440,19 @@ description: First agent Agent 1 prompt`; mockFileSystem.mockFile(URI.joinPath(orgDir, 'agent1.agent.md'), agentContent); - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); // With error handling, partial failures skip cache update for that org // So the existing file cache is returned with the one successful agent const cachedAgents = await provider.provideCustomAgents({}, {} as any); assert.equal(cachedAgents.length, 1); - assert.equal(cachedAgents[0].name, 'agent1'); + const cachedAgentName = (cachedAgents[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(cachedAgentName, 'agent1'); }); test('caches agents in memory after first successful fetch', async () => { - const provider = createProvider(); - - // Initial setup with one agent + // Initial setup with one agent BEFORE creating provider const initialAgent: CustomAgentListItem = { name: 'initial_agent', repo_owner_id: 1, @@ -469,13 +470,14 @@ Agent 1 prompt`; prompt: 'Initial prompt', }); - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); // After successful fetch, subsequent calls return from memory const agents1 = await provider.provideCustomAgents({}, {} as any); assert.equal(agents1.length, 1); - assert.equal(agents1[0].name, 'initial_agent'); + const agentName1 = (agents1[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(agentName1, 'initial_agent'); // Even if API is updated, memory cache is used const newAgent: CustomAgentListItem = { @@ -498,13 +500,12 @@ Agent 1 prompt`; // Memory cache returns old results without refetching const agents2 = await provider.provideCustomAgents({}, {} as any); assert.equal(agents2.length, 1); - assert.equal(agents2[0].name, 'initial_agent'); + const agentName2ForMemory = (agents2[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(agentName2ForMemory, 'initial_agent'); }); test('memory cache persists after first successful fetch', async () => { - const provider = createProvider(); - - // Initial setup with two agents + // Initial setup with two agents BEFORE creating provider const agents: CustomAgentListItem[] = [ { name: 'agent1', @@ -533,7 +534,7 @@ Agent 1 prompt`; mockOctoKitService.setAgentDetails('agent1', { ...agents[0], prompt: 'Prompt 1' }); mockOctoKitService.setAgentDetails('agent2', { ...agents[1], prompt: 'Prompt 2' }); - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); // Verify both agents are cached @@ -546,8 +547,10 @@ Agent 1 prompt`; // Memory cache still returns both agents (no refetch) const cachedAgents2 = await provider.provideCustomAgents({}, {} as any); assert.equal(cachedAgents2.length, 2); - assert.equal(cachedAgents2[0].name, 'agent1'); - assert.equal(cachedAgents2[1].name, 'agent2'); + const cachedAgent2Name1 = (cachedAgents2[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + const cachedAgent2Name2 = (cachedAgents2[1].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(cachedAgent2Name1, 'agent1'); + assert.equal(cachedAgent2Name2, 'agent2'); }); test('does not fire change event when content is identical', async () => { @@ -587,9 +590,7 @@ Agent 1 prompt`; }); test('memory cache persists even when API returns empty list', async () => { - const provider = createProvider(); - - // Setup with initial agents + // Setup with initial agents BEFORE creating provider const mockAgent: CustomAgentListItem = { name: 'temporary_agent', repo_owner_id: 1, @@ -607,7 +608,7 @@ Agent 1 prompt`; prompt: 'Temporary prompt', }); - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); // Verify agent is cached @@ -620,7 +621,8 @@ Agent 1 prompt`; // Memory cache still returns the agent (no refetch) const agents2 = await provider.provideCustomAgents({}, {} as any); assert.equal(agents2.length, 1); - assert.equal(agents2[0].name, 'temporary_agent'); + const temporaryAgentName = (agents2[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(temporaryAgentName, 'temporary_agent'); }); test('generates markdown with only required fields', async () => { @@ -701,9 +703,10 @@ Agent 1 prompt`; }); test('handles malformed frontmatter in cached files', async () => { - const provider = createProvider(); + // Prevent background fetch from interfering + mockOctoKitService.setUserOrganizations([]); - // Pre-populate cache with mixed valid and malformed content + // Pre-populate cache with mixed valid and malformed content BEFORE creating provider const cacheDir = URI.joinPath(mockExtensionContext.globalStorageUri!, 'githubAgentsCache'); const orgDir = URI.joinPath(cacheDir, 'testorg'); mockFileSystem.mockDirectory(cacheDir, [['testorg', FileType.Directory]]); @@ -723,14 +726,19 @@ Valid prompt`; const noFrontmatterContent = `Just some content without any frontmatter`; mockFileSystem.mockFile(URI.joinPath(orgDir, 'no_frontmatter.agent.md'), noFrontmatterContent); + const provider = createProvider(); + + // Wait for background fetch to complete (returns early due to no orgs) + await new Promise(resolve => setTimeout(resolve, 50)); + 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, ''); + const validAgentName = (agents[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(validAgentName, 'valid_agent'); + const noFrontmatterAgentName = (agents[1].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(noFrontmatterAgentName, 'no_frontmatter'); }); test('fetches agents from all user organizations', async () => { @@ -914,9 +922,7 @@ Test prompt }); test('deduplicates enterprise agents that appear in multiple organizations', async () => { - const provider = createProvider(); - - // Setup multiple organizations + // Setup multiple organizations BEFORE creating provider mockOctoKitService.setUserOrganizations(['orgA', 'orgB']); // Create an enterprise agent that will appear in both organizations @@ -943,14 +949,15 @@ Test prompt prompt: 'Enterprise prompt', }); - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); const agents = await provider.provideCustomAgents({}, {} as any); // Should only have one agent, not two (deduped) assert.equal(agents.length, 1); - assert.equal(agents[0].name, 'enterprise_agent'); + const enterpriseAgentName = (agents[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(enterpriseAgentName, 'enterprise_agent'); // Verify it was only written to one org directory const cacheDir = URI.joinPath(mockExtensionContext.globalStorageUri!, 'githubAgentsCache'); @@ -980,8 +987,7 @@ Test prompt }); test('deduplicates agents with same repo regardless of version', async () => { - const provider = createProvider(); - + // Set up mocks BEFORE creating provider mockOctoKitService.setUserOrganizations(['orgA', 'orgB']); // Create agents with same name but different versions @@ -1030,19 +1036,19 @@ Test prompt return undefined; }; - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); const agents = await provider.provideCustomAgents({}, {} as any); // Different versions are deduplicated, only the first one is kept assert.equal(agents.length, 1); - assert.equal(agents[0].name, 'versioned_agent'); + const versionedAgentName = (agents[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(versionedAgentName, 'versioned_agent'); }); test('does not deduplicate org-specific agents with same name from different orgs', async () => { - const provider = createProvider(); - + // Set up mocks BEFORE creating provider mockOctoKitService.setUserOrganizations(['orgA', 'orgB']); // Create agents with same name but from different org repos (not enterprise) @@ -1089,20 +1095,21 @@ Test prompt return undefined; }; - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); const agents = await provider.provideCustomAgents({}, {} as any); // Should have 2 agents since they're from different repos (not duplicates) assert.equal(agents.length, 2); - assert.equal(agents[0].name, 'org_agent'); - assert.equal(agents[1].name, 'org_agent'); + const orgAgentName1 = (agents[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + const orgAgentName2 = (agents[1].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(orgAgentName1, 'org_agent'); + assert.equal(orgAgentName2, 'org_agent'); }); test('deduplicates enterprise agents even when API returns them in different order', async () => { - const provider = createProvider(); - + // Set up mocks BEFORE creating provider mockOctoKitService.setUserOrganizations(['orgA', 'orgB', 'orgC']); const enterpriseAgent1: CustomAgentListItem = { @@ -1151,7 +1158,7 @@ Test prompt return undefined; }; - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); const agents = await provider.provideCustomAgents({}, {} as any); @@ -1160,13 +1167,12 @@ Test prompt assert.equal(agents.length, 2); // Verify both agent names are present - const agentNames = agents.map(a => a.name).sort(); + const agentNames = agents.map(a => (a.resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', '')).sort(); assert.deepEqual(agentNames, ['enterprise_agent1', 'enterprise_agent2']); }); test('deduplication key does not include version so different versions are deduplicated', async () => { - const provider = createProvider(); - + // Set up mocks BEFORE creating provider mockOctoKitService.setUserOrganizations(['orgA']); // Same agent with two different versions @@ -1200,13 +1206,14 @@ Test prompt return undefined; }; - await provider.provideCustomAgents({}, {} as any); + const provider = createProvider(); await new Promise(resolve => setTimeout(resolve, 100)); const agents = await provider.provideCustomAgents({}, {} as any); // Different versions are deduplicated, only the first one is kept assert.equal(agents.length, 1); - assert.equal(agents[0].name, 'multi_version_agent'); + const multiVersionAgentName = (agents[0].resource as vscode.Uri).path.split('/').pop()?.replace('.agent.md', ''); + assert.equal(multiVersionAgentName, 'multi_version_agent'); }); }); diff --git a/src/extension/extension/vscode-node/contributions.ts b/src/extension/extension/vscode-node/contributions.ts index 405113f0f6..e5a0e7de2e 100644 --- a/src/extension/extension/vscode-node/contributions.ts +++ b/src/extension/extension/vscode-node/contributions.ts @@ -3,7 +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 { PromptFileContribution } from '../../agents/vscode-node/promptFileContrib'; import { AuthenticationContrib } from '../../authentication/vscode-node/authentication.contribution'; import { BYOKContrib } from '../../byok/vscode-node/byokContribution'; import { ChatQuotaContribution } from '../../chat/vscode-node/chatQuota.contribution'; @@ -116,6 +116,6 @@ export const vscodeNodeChatContributions: IExtensionContributionFactory[] = [ asContributionFactory(BYOKContrib), asContributionFactory(McpSetupCommands), asContributionFactory(LanguageModelProxyContrib), - asContributionFactory(OrganizationAndEnterpriseAgentContribution), + asContributionFactory(PromptFileContribution), newWorkspaceContribution, ]; diff --git a/src/extension/tools/node/toolUtils.ts b/src/extension/tools/node/toolUtils.ts index c1e5a750e2..efe4e7a09d 100644 --- a/src/extension/tools/node/toolUtils.ts +++ b/src/extension/tools/node/toolUtils.ts @@ -109,7 +109,7 @@ export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI): const normalizedUri = normalizePath(uri); - if (!workspaceService.getWorkspaceFolder(normalizedUri) && uri.scheme !== Schemas.untitled && !customInstructionsService.isExternalInstructionsFile(normalizedUri)) { + if (!workspaceService.getWorkspaceFolder(normalizedUri) && uri.scheme !== Schemas.untitled && !await customInstructionsService.isExternalInstructionsFile(normalizedUri)) { const fileOpenInSomeTab = tabsAndEditorsService.tabs.some(tab => isEqual(tab.uri, uri)); if (!fileOpenInSomeTab) { throw new Error(`File ${promptPathRepresentationService.getFilePath(normalizedUri)} is outside of the workspace, and not open in an editor, and can't be read`); diff --git a/src/extension/vscode.proposed.chatParticipantPrivate.d.ts b/src/extension/vscode.proposed.chatParticipantPrivate.d.ts index 8a809de994..21de777bc5 100644 --- a/src/extension/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/extension/vscode.proposed.chatParticipantPrivate.d.ts @@ -314,65 +314,4 @@ 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; - - /** - * Indicates whether the custom agent resource is editable. Defaults to false. - */ - readonly isEditable?: boolean; - } - - /** - * Options for querying custom agents. - */ - export interface CustomAgentQueryOptions { } - - /** - * 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. - */ - readonly 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/extension/vscode.proposed.chatPromptFiles.d.ts b/src/extension/vscode.proposed.chatPromptFiles.d.ts new file mode 100644 index 0000000000..b0da5fe132 --- /dev/null +++ b/src/extension/vscode.proposed.chatPromptFiles.d.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 1 + +declare module 'vscode' { + + // #region Resource Classes + + /** + * Describes a chat resource file. + */ + export type ChatResourceDescriptor = + | Uri + | { + uri: Uri; + isEditable?: boolean; + } + | { + id: string; + content: string; + }; + + /** + * Represents a custom agent resource file (e.g., .agent.md). + */ + export class CustomAgentChatResource { + /** + * The custom agent resource descriptor. + */ + readonly resource: ChatResourceDescriptor; + + /** + * Creates a new custom agent resource from the specified resource. + * @param resource The chat resource descriptor. + */ + constructor(resource: ChatResourceDescriptor); + } + + /** + * Represents an instructions resource file. + */ + export class InstructionsChatResource { + /** + * The instructions resource descriptor. + */ + readonly resource: ChatResourceDescriptor; + + /** + * Creates a new instructions resource from the specified resource. + * @param resource The chat resource descriptor. + */ + constructor(resource: ChatResourceDescriptor); + } + + /** + * Represents a prompt file resource (e.g., .prompt.md). + */ + export class PromptFileChatResource { + /** + * The prompt file resource descriptor. + */ + readonly resource: ChatResourceDescriptor; + + /** + * Creates a new prompt file resource from the specified resource. + * @param resource The chat resource descriptor. + */ + constructor(resource: ChatResourceDescriptor); + } + + // #endregion + + // #region Providers + + /** + * Options for querying custom agents. + */ + export type CustomAgentContext = object; + + /** + * A provider that supplies custom agent resources (from .agent.md files) for repositories. + */ + export interface CustomAgentProvider { + /** + * A human-readable label for this provider. + */ + readonly label: string; + + /** + * An optional event to signal that custom agents have changed. + */ + readonly onDidChangeCustomAgents?: Event; + + /** + * Provide the list of custom agents available. + * @param context Context for the query. + * @param token A cancellation token. + * @returns An array of custom agents or a promise that resolves to such. + */ + provideCustomAgents( + context: CustomAgentContext, + token: CancellationToken + ): ProviderResult; + } + + /** + * Context for querying instructions. + */ + export type InstructionsContext = object; + + /** + * A provider that supplies instructions resources for repositories. + */ + export interface InstructionsProvider { + /** + * A human-readable label for this provider. + */ + readonly label: string; + + /** + * An optional event to signal that instructions have changed. + */ + readonly onDidChangeInstructions?: Event; + + /** + * Provide the list of instructions available. + * @param context Context for the query. + * @param token A cancellation token. + * @returns An array of instructions or a promise that resolves to such. + */ + provideInstructions( + context: InstructionsContext, + token: CancellationToken + ): ProviderResult; + } + + /** + * Context for querying prompt files. + */ + export type PromptFileContext = object; + + /** + * A provider that supplies prompt file resources (from .prompt.md files) for repositories. + */ + export interface PromptFileProvider { + /** + * A human-readable label for this provider. + */ + readonly label: string; + + /** + * An optional event to signal that prompt files have changed. + */ + readonly onDidChangePromptFiles?: Event; + + /** + * Provide the list of prompt files available. + * @param context Context for the query. + * @param token A cancellation token. + * @returns An array of prompt files or a promise that resolves to such. + */ + providePromptFiles( + context: PromptFileContext, + token: CancellationToken + ): ProviderResult; + } + + // #endregion + + // #region Chat Provider Registration + + export namespace chat { + /** + * Register a provider for custom agents. + * @param provider The custom agent provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerCustomAgentProvider( + provider: CustomAgentProvider + ): Disposable; + + /** + * Register a provider for instructions. + * @param provider The instructions provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerInstructionsProvider( + provider: InstructionsProvider + ): Disposable; + + /** + * Register a provider for prompt files. + * @param provider The prompt file provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerPromptFileProvider( + provider: PromptFileProvider + ): Disposable; + } + + // #endregion +} diff --git a/src/platform/customInstructions/common/customInstructionsService.ts b/src/platform/customInstructions/common/customInstructionsService.ts index ee873bab14..2687a40c7a 100644 --- a/src/platform/customInstructions/common/customInstructionsService.ts +++ b/src/platform/customInstructions/common/customInstructionsService.ts @@ -15,6 +15,7 @@ import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resourc import { isObject } from '../../../util/vs/base/common/types'; import { URI } from '../../../util/vs/base/common/uri'; import { FileType, Uri } from '../../../vscodeTypes'; +import { IRunCommandExecutionService } from '../../commands/common/runCommandExecutionService'; import { CodeGenerationImportInstruction, CodeGenerationTextInstruction, Config, ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { INativeEnvService } from '../../env/common/envService'; import { IExtensionsService } from '../../extensions/common/extensionsService'; @@ -53,7 +54,7 @@ export interface ICustomInstructionsService { getAgentInstructions(): Promise; - isExternalInstructionsFile(uri: URI): boolean; + isExternalInstructionsFile(uri: URI): Promise; isExternalInstructionsFolder(uri: URI): boolean; isSkillFile(uri: URI): boolean; isSkillMdFile(uri: URI): boolean; @@ -102,6 +103,7 @@ export class CustomInstructionsService extends Disposable implements ICustomInst @IPromptPathRepresentationService private readonly promptPathRepresentationService: IPromptPathRepresentationService, @ILogService private readonly logService: ILogService, @IExtensionsService private readonly extensionService: IExtensionsService, + @IRunCommandExecutionService private readonly runCommandExecutionService: IRunCommandExecutionService, ) { super(); @@ -303,13 +305,37 @@ export class CustomInstructionsService extends Disposable implements ICustomInst } } - public isExternalInstructionsFile(uri: URI): boolean { + public async isExternalInstructionsFile(uri: URI): Promise { if (uri.scheme === Schemas.vscodeUserData && uri.path.endsWith(INSTRUCTION_FILE_EXTENSION)) { return true; } - return this._matchInstructionLocationsFromConfig.get()(uri) + if (this._matchInstructionLocationsFromConfig.get()(uri) || this._matchInstructionLocationsFromExtensions.get()(uri) - || this._matchInstructionLocationsFromSkills.get()(uri) !== undefined; + || this._matchInstructionLocationsFromSkills.get()(uri)) { + return true; + } + + // Check for external extension-contributed prompt files + try { + const extensionPromptFiles = await this.runCommandExecutionService.executeCommand('vscode.extensionPromptFileProvider') as { + uri: URI; type: 'instructions' | 'prompt' | 'agent' | 'skill'; + }[] | undefined; + if (extensionPromptFiles) { + return extensionPromptFiles.some(file => { + if (file.type === 'skill') { + // For skills, the URI points to SKILL.md - allow everything under the parent folder + const skillFolderUri = extUriBiasedIgnorePathCase.dirname(file.uri); + return extUriBiasedIgnorePathCase.isEqualOrParent(uri, skillFolderUri); + } + return extUriBiasedIgnorePathCase.isEqual(file.uri, uri); + }); + } + } catch (e) { + this.logService.warn('Error checking for extension prompt files'); + // Command may not be available, ignore + } + + return false; } public isExternalInstructionsFolder(uri: URI): boolean { diff --git a/src/platform/customInstructions/test/node/customInstructionsService.spec.ts b/src/platform/customInstructions/test/node/customInstructionsService.spec.ts index 5dde21c8bb..18f4af2736 100644 --- a/src/platform/customInstructions/test/node/customInstructionsService.spec.ts +++ b/src/platform/customInstructions/test/node/customInstructionsService.spec.ts @@ -167,14 +167,14 @@ suite('CustomInstructionsService - Skills', () => { }); suite('isExternalInstructionsFile', () => { - test('should return true for skill files', () => { + test('should return true for skill files', async () => { const skillFileUri = URI.file('/workspace/.github/skills/myskill/SKILL.md'); - expect(customInstructionsService.isExternalInstructionsFile(skillFileUri)).toBe(true); + expect(await customInstructionsService.isExternalInstructionsFile(skillFileUri)).toBe(true); }); - test('should return false for regular files', () => { + test('should return false for regular files', async () => { const regularFileUri = URI.file('/workspace/src/file.ts'); - expect(customInstructionsService.isExternalInstructionsFile(regularFileUri)).toBe(false); + expect(await customInstructionsService.isExternalInstructionsFile(regularFileUri)).toBe(false); }); }); diff --git a/src/platform/test/common/testCustomInstructionsService.ts b/src/platform/test/common/testCustomInstructionsService.ts index 3bff1adcf4..cca8c070cc 100644 --- a/src/platform/test/common/testCustomInstructionsService.ts +++ b/src/platform/test/common/testCustomInstructionsService.ts @@ -79,8 +79,8 @@ export class MockCustomInstructionsService implements ICustomInstructionsService return { skillName, skillFolderUri }; } - isExternalInstructionsFile(uri: URI): boolean { - return this.externalFiles.has(uri.toString()); + isExternalInstructionsFile(uri: URI): Promise { + return Promise.resolve(this.externalFiles.has(uri.toString())); } isExternalInstructionsFolder(uri: URI): boolean { diff --git a/src/util/common/test/shims/chatTypes.ts b/src/util/common/test/shims/chatTypes.ts index f05e51b50a..5ef139134e 100644 --- a/src/util/common/test/shims/chatTypes.ts +++ b/src/util/common/test/shims/chatTypes.ts @@ -521,4 +521,15 @@ export class LanguageModelError extends Error { this.name = LanguageModelError.#name; this.code = code ?? ''; } +} + +/** + * Represents a custom agent resource file (e.g., .agent.md). + */ +export class CustomAgentChatResource implements vscode.CustomAgentChatResource { + readonly resource: vscode.ChatResourceDescriptor; + + constructor(resource: vscode.ChatResourceDescriptor) { + this.resource = resource; + } } \ No newline at end of file diff --git a/src/util/common/test/shims/vscodeTypesShim.ts b/src/util/common/test/shims/vscodeTypesShim.ts index 3e36b3f191..afaf6732d2 100644 --- a/src/util/common/test/shims/vscodeTypesShim.ts +++ b/src/util/common/test/shims/vscodeTypesShim.ts @@ -18,7 +18,7 @@ import { SnippetString } from '../../../vs/workbench/api/common/extHostTypes/sni import { SnippetTextEdit } from '../../../vs/workbench/api/common/extHostTypes/snippetTextEdit'; import { SymbolInformation, SymbolKind } from '../../../vs/workbench/api/common/extHostTypes/symbolInformation'; import { EndOfLine, TextEdit } from '../../../vs/workbench/api/common/extHostTypes/textEdit'; -import { AISearchKeyword, ChatErrorLevel, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatSessionStatus, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, TextSearchMatch2 } from './chatTypes'; +import { AISearchKeyword, ChatErrorLevel, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatSessionStatus, ChatToolInvocationPart, CustomAgentChatResource, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, TextSearchMatch2 } from './chatTypes'; import { TextDocumentChangeReason, TextEditorSelectionChangeKind, WorkspaceEdit } from './editing'; import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums'; import { t } from './l10n'; @@ -115,6 +115,7 @@ const shim: typeof vscodeTypes = { ChatResponseTurn2, ChatRequestTurn2: ChatRequestTurn, LanguageModelError: LanguageModelError as any, // Some difference in the definition of Error is breaking this + CustomAgentChatResource, SymbolKind, SnippetString, SnippetTextEdit, diff --git a/src/vscodeTypes.ts b/src/vscodeTypes.ts index 94897c3ba9..c6a2204214 100644 --- a/src/vscodeTypes.ts +++ b/src/vscodeTypes.ts @@ -98,6 +98,7 @@ export import SnippetString = vscode.SnippetString; export import SnippetTextEdit = vscode.SnippetTextEdit; export import FileType = vscode.FileType; export import ChatSessionStatus = vscode.ChatSessionStatus; +export import CustomAgentChatResource = vscode.CustomAgentChatResource; export const l10n = { /**