diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 348cf81f5f5..392d86f6139 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -38,6 +38,11 @@ export interface IAgentPluginAgent { readonly name: string; } +export interface IAgentPluginInstruction { + readonly uri: URI; + readonly name: string; +} + export interface IAgentPluginMcpServerDefinition { readonly name: string; readonly configuration: IMcpServerConfiguration; @@ -54,6 +59,7 @@ export interface IAgentPlugin { readonly commands: IObservable; readonly skills: IObservable; readonly agents: IObservable; + readonly instructions: IObservable; readonly mcpServerDefinitions: IObservable; /** Set when the plugin was installed from a marketplace repository. */ readonly fromMarketplace?: IMarketplacePlugin; diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 563b9ece99f..4d5cbf8eff6 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -37,11 +37,14 @@ import { parseClaudeHooks } from '../promptSyntax/hookClaudeCompat.js'; import { parseCopilotHooks } from '../promptSyntax/hookCompatibility.js'; import { IHookCommand } from '../promptSyntax/hookSchema.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; -import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js'; +import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; const COMMAND_FILE_SUFFIX = '.md'; +/** File suffixes accepted for rule/instruction files (longest first for correct name stripping). */ +const RULE_FILE_SUFFIXES = ['.instructions.md', '.mdc', '.md']; + const enum AgentPluginFormat { Copilot, Claude, @@ -509,6 +512,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const commands = observeComponent('commands', d => this._readMarkdownComponents(d)); const skills = observeComponent('skills', d => this._readSkills(d)); const agents = observeComponent('agents', d => this._readMarkdownComponents(d)); + const instructions = observeComponent('rules', d => this._readRules(d)); const hooks = observeComponent( 'hooks', paths => this._readHooksFromPaths(uri, paths, adapter), @@ -549,6 +553,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements commands, skills, agents, + instructions, mcpServerDefinitions, fromMarketplace, }; @@ -737,6 +742,62 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements return skills; } + /** + * Scans directories for rule/instruction files (`.mdc`, `.md`, + * `.instructions.md`), returning `{ uri, name }` entries where name is + * derived from the filename minus the matched suffix. + */ + private async _readRules(dirs: readonly URI[]): Promise { + const seen = new Set(); + const items: IAgentPluginInstruction[] = []; + + const matchSuffix = (filename: string): string | undefined => { + const lower = filename.toLowerCase(); + return RULE_FILE_SUFFIXES.find(s => lower.endsWith(s)); + }; + + const addItem = (name: string, uri: URI) => { + if (!seen.has(name)) { + seen.add(name); + items.push({ uri, name }); + } + }; + + for (const dir of dirs) { + let stat; + try { + stat = await this._fileService.resolve(dir); + } catch { + continue; + } + + if (stat.isFile) { + const suffix = matchSuffix(basename(dir)); + if (suffix) { + addItem(basename(dir).slice(0, -suffix.length), dir); + } + continue; + } + + if (!stat.isDirectory || !stat.children) { + continue; + } + + for (const child of stat.children) { + if (!child.isFile) { + continue; + } + const suffix = matchSuffix(child.name); + if (suffix) { + addItem(child.name.slice(0, -suffix.length), child.resource); + } + } + } + + items.sort((a, b) => a.name.localeCompare(b.name)); + return items; + } + /** * Scans directories for `.md` files, returning `{ uri, name }` entries * where name is derived from the filename (minus the `.md` extension). diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 70aab5ee438..930c9390c2b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -214,6 +214,7 @@ export class PromptsService extends Disposable implements IPromptsService { Event.filter(modelChangeEvent, e => e.promptType === PromptsType.agent), this._onDidContributedWhenChange.event, Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)), + this._onDidPluginPromptFilesChange.event, ) )); @@ -259,6 +260,10 @@ export class PromptsService extends Disposable implements IPromptsService { PromptsType.agent, (plugin, reader) => plugin.agents.read(reader), )); + this._register(this.watchPluginPromptFilesForType( + PromptsType.instructions, + (plugin, reader) => plugin.instructions.read(reader), + )); this._register(autorun(reader => { const plugins = this.agentPluginService.plugins.read(reader); @@ -671,6 +676,7 @@ export class PromptsService extends Disposable implements IPromptsService { this.getFileLocatorEvent(PromptsType.instructions), this._onDidContributedWhenChange.event, this._onDidChangeInstructions.event, + this._onDidPluginPromptFilesChange.event, ); } diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts index 4f05e048dfc..9e79f04f26b 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts @@ -401,6 +401,7 @@ suite('AgentPlugin format detection', () => { await writeFile('/plugins/no-manifest/commands/hello.md', '# Hello'); await writeFile('/plugins/no-manifest/skills/my-skill/SKILL.md', '# My skill'); await writeFile('/plugins/no-manifest/agents/helper.md', '# Helper'); + await writeFile('/plugins/no-manifest/rules/prefer-const.mdc', '---\ndescription: Prefer const\n---\nUse const.'); const discovery = createDiscovery(); discovery.start(mockEnablementModel); @@ -418,6 +419,9 @@ suite('AgentPlugin format detection', () => { await waitForState(plugins[0].agents, a => a.length > 0); assert.strictEqual(plugins[0].agents.get()[0].name, 'helper'); + + await waitForState(plugins[0].instructions, i => i.length > 0); + assert.strictEqual(plugins[0].instructions.get()[0].name, 'prefer-const'); })); test('reads hooks from default hooks/hooks.json', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -701,4 +705,132 @@ suite('AgentPlugin format detection', () => { await waitForState(plugins[0].mcpServerDefinitions, d => d.length > 0); assert.strictEqual(plugins[0].mcpServerDefinitions.get()[0].name, 'custom-server'); })); + + test('reads rules from rules/ directory with .mdc extension', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = pluginUri('/plugins/rules-plugin'); + await writeFile('/plugins/rules-plugin/.plugin/plugin.json', JSON.stringify({ name: 'rules-plugin' })); + await writeFile('/plugins/rules-plugin/rules/prefer-const.mdc', '---\ndescription: Prefer const\n---\nUse const.'); + await writeFile('/plugins/rules-plugin/rules/error-handling.mdc', '---\ndescription: Error handling\n---\nAlways handle errors.'); + + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setSourcesAndRefresh([uri]); + + const plugins = discovery.plugins.get(); + assert.strictEqual(plugins.length, 1); + + await waitForState(plugins[0].instructions, i => i.length >= 2); + assert.deepStrictEqual( + plugins[0].instructions.get().map(i => i.name).sort(), + ['error-handling', 'prefer-const'], + ); + })); + + test('reads rules with .md and .instructions.md extensions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = pluginUri('/plugins/rules-mixed'); + await writeFile('/plugins/rules-mixed/.plugin/plugin.json', JSON.stringify({ name: 'rules-mixed' })); + await writeFile('/plugins/rules-mixed/rules/rule-a.mdc', 'Rule A'); + await writeFile('/plugins/rules-mixed/rules/rule-b.md', 'Rule B'); + await writeFile('/plugins/rules-mixed/rules/rule-c.instructions.md', 'Rule C'); + + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setSourcesAndRefresh([uri]); + + const plugins = discovery.plugins.get(); + assert.strictEqual(plugins.length, 1); + + await waitForState(plugins[0].instructions, i => i.length >= 3); + assert.deepStrictEqual( + plugins[0].instructions.get().map(i => i.name).sort(), + ['rule-a', 'rule-b', 'rule-c'], + ); + })); + + test('manifest rules field adds supplemental rule directories', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = pluginUri('/plugins/custom-rules'); + await writeFile('/plugins/custom-rules/.plugin/plugin.json', JSON.stringify({ + name: 'custom-rules', + rules: './extra-rules/', + })); + await writeFile('/plugins/custom-rules/rules/default-rule.mdc', 'Default rule'); + await writeFile('/plugins/custom-rules/extra-rules/bonus-rule.mdc', 'Bonus rule'); + + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setSourcesAndRefresh([uri]); + + const plugins = discovery.plugins.get(); + assert.strictEqual(plugins.length, 1); + + await waitForState(plugins[0].instructions, i => i.length >= 2); + assert.deepStrictEqual( + plugins[0].instructions.get().map(i => i.name).sort(), + ['bonus-rule', 'default-rule'], + ); + })); + + test('manifest rules field with exclusive mode skips default directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = pluginUri('/plugins/exclusive-rules'); + await writeFile('/plugins/exclusive-rules/.plugin/plugin.json', JSON.stringify({ + name: 'exclusive-rules', + rules: { paths: ['./only-here/'], exclusive: true }, + })); + await writeFile('/plugins/exclusive-rules/rules/ignored.mdc', 'Should be ignored'); + await writeFile('/plugins/exclusive-rules/only-here/visible.mdc', 'Should be visible'); + + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setSourcesAndRefresh([uri]); + + const plugins = discovery.plugins.get(); + assert.strictEqual(plugins.length, 1); + + await waitForState(plugins[0].instructions, i => i.length === 1 && i[0].name === 'visible'); + assert.deepStrictEqual( + plugins[0].instructions.get().map(i => i.name), + ['visible'], + ); + })); + + test('rule name strips longest matching suffix first', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = pluginUri('/plugins/suffix-rules'); + await writeFile('/plugins/suffix-rules/.plugin/plugin.json', JSON.stringify({ name: 'suffix-rules' })); + await writeFile('/plugins/suffix-rules/rules/coding-standards.instructions.md', 'Standards'); + + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setSourcesAndRefresh([uri]); + + const plugins = discovery.plugins.get(); + assert.strictEqual(plugins.length, 1); + + await waitForState(plugins[0].instructions, i => i.length > 0); + // Should strip '.instructions.md' (longest match), not just '.md' + assert.strictEqual(plugins[0].instructions.get()[0].name, 'coding-standards'); + })); + + test('deduplicates rules with the same base name', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = pluginUri('/plugins/dup-rules'); + await writeFile('/plugins/dup-rules/.plugin/plugin.json', JSON.stringify({ + name: 'dup-rules', + rules: './extra/', + })); + // Default directory has 'my-rule.mdc', supplemental has 'my-rule.md' — first wins + await writeFile('/plugins/dup-rules/rules/my-rule.mdc', 'From default'); + await writeFile('/plugins/dup-rules/extra/my-rule.md', 'From extra'); + + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setSourcesAndRefresh([uri]); + + const plugins = discovery.plugins.get(); + assert.strictEqual(plugins.length, 1); + + await waitForState(plugins[0].instructions, i => i.length > 0); + assert.strictEqual(plugins[0].instructions.get().length, 1); + const instruction = plugins[0].instructions.get()[0]; + assert.strictEqual(instruction.name, 'my-rule'); + assert.ok(instruction.uri.path.endsWith('/rules/my-rule.mdc')); + })); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 7780715e06b..162a1a65b9b 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -53,7 +53,7 @@ import { ChatModeKind } from '../../../../common/constants.js'; import { HookType } from '../../../../common/promptSyntax/hookTypes.js'; import { IContextKeyService, IContextKeyChangeEvent } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js'; +import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js'; import { IWorkspaceTrustManagementService } from '../../../../../../../platform/workspace/common/workspaceTrust.js'; suite('PromptsService', () => { @@ -3684,6 +3684,7 @@ suite('PromptsService', () => { const commands = observableValue('testPluginCommands', []); const skills = observableValue('testPluginSkills', []); const agents = observableValue('testPluginAgents', []); + const instructions = observableValue('testPluginInstructions', []); const mcpServerDefinitions = observableValue('testPluginMcpServerDefinitions', []); return { @@ -3696,6 +3697,7 @@ suite('PromptsService', () => { commands, skills, agents, + instructions, mcpServerDefinitions, }, hooks, @@ -3855,4 +3857,110 @@ suite('PromptsService', () => { assert.strictEqual(result, undefined, 'Expected undefined hooks when workspace is untrusted, even with plugin hooks'); }); }); + + suite('plugin instructions', () => { + function createPluginWithInstructions( + path: string, + initialInstructions: readonly IAgentPluginInstruction[], + ): { plugin: IAgentPlugin; instructions: ISettableObservable } { + const enablement = observableValue('testPluginEnablement', 2 /* ContributionEnablementState.EnabledProfile */); + const hooks = observableValue('testPluginHooks', []); + const commands = observableValue('testPluginCommands', []); + const skills = observableValue('testPluginSkills', []); + const agents = observableValue('testPluginAgents', []); + const instructions = observableValue('testPluginInstructions', initialInstructions); + const mcpServerDefinitions = observableValue('testPluginMcpServerDefinitions', []); + + return { + plugin: { + uri: URI.file(path), + label: basename(URI.file(path)), + enablement, + remove: () => { }, + hooks, + commands, + skills, + agents, + instructions, + mcpServerDefinitions, + }, + instructions, + }; + } + + test('lists plugin instructions via listPromptFiles', async function () { + const ruleUri = URI.file('/plugins/test-plugin/rules/prefer-const.mdc'); + const { plugin } = createPluginWithInstructions('/plugins/test-plugin', [ + { uri: ruleUri, name: 'prefer-const' }, + ]); + + testPluginsObservable.set([plugin], undefined); + + const result = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + const pluginInstruction = result.find(p => p.uri.toString() === ruleUri.toString()); + assert.ok(pluginInstruction, 'Plugin instruction should appear in listPromptFiles'); + assert.strictEqual(pluginInstruction!.storage, PromptsStorage.plugin); + }); + + test('updates listed instructions when plugin instructions change', async function () { + const ruleUri1 = URI.file('/plugins/test-plugin/rules/rule-a.mdc'); + const ruleUri2 = URI.file('/plugins/test-plugin/rules/rule-b.mdc'); + const { plugin, instructions } = createPluginWithInstructions('/plugins/test-plugin', [ + { uri: ruleUri1, name: 'rule-a' }, + ]); + + testPluginsObservable.set([plugin], undefined); + + const before = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + const beforePlugin = before.filter(p => p.storage === PromptsStorage.plugin); + assert.strictEqual(beforePlugin.length, 1); + + const eventFired = new Promise(resolve => { + const disposable = service.onDidChangeInstructions(() => { + disposable.dispose(); + resolve(); + }); + }); + + instructions.set([ + { uri: ruleUri1, name: 'rule-a' }, + { uri: ruleUri2, name: 'rule-b' }, + ], undefined); + + await eventFired; + + const after = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + const afterPlugin = after.filter(p => p.storage === PromptsStorage.plugin); + assert.strictEqual(afterPlugin.length, 2); + }); + + test('removes instructions when plugin is removed', async function () { + const ruleUri = URI.file('/plugins/test-plugin/rules/rule-a.mdc'); + const { plugin } = createPluginWithInstructions('/plugins/test-plugin', [ + { uri: ruleUri, name: 'rule-a' }, + ]); + + testPluginsObservable.set([plugin], undefined); + const withPlugin = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + assert.ok(withPlugin.some(p => p.storage === PromptsStorage.plugin)); + + testPluginsObservable.set([], undefined); + const withoutPlugin = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + assert.ok(!withoutPlugin.some(p => p.storage === PromptsStorage.plugin)); + }); + + test('namespaces plugin instruction names with plugin folder', async function () { + const ruleUri = URI.file('/plugins/deploy-tools/rules/lint-check.mdc'); + const { plugin } = createPluginWithInstructions('/plugins/deploy-tools', [ + { uri: ruleUri, name: 'lint-check' }, + ]); + + testPluginsObservable.set([plugin], undefined); + + const result = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + const pluginInstruction = result.find(p => p.uri.toString() === ruleUri.toString()); + assert.ok(pluginInstruction, 'Plugin instruction should be listed'); + assert.strictEqual(pluginInstruction!.name, 'deploy-tools:lint-check'); + }); + }); });