diff --git a/.vscode/extensions/vscode-selfhost-import-aid/tsconfig.json b/.vscode/extensions/vscode-selfhost-import-aid/tsconfig.json index bbca76708ca51..dfb8b9839531b 100644 --- a/.vscode/extensions/vscode-selfhost-import-aid/tsconfig.json +++ b/.vscode/extensions/vscode-selfhost-import-aid/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../../extensions/tsconfig.base.json", "compilerOptions": { + "rootDir": "./src", "outDir": "./out", "types": [ "node", diff --git a/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json b/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json index e40cd7d749292..ea1ecc9aa9565 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json +++ b/.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../../extensions/tsconfig.base.json", "compilerOptions": { + "rootDir": "./src", "outDir": "./out", "types": [ "node", diff --git a/build/lib/watch/index.ts b/build/lib/watch/index.ts index 42d9f9a4a933f..6276744e84fd3 100644 --- a/build/lib/watch/index.ts +++ b/build/lib/watch/index.ts @@ -34,12 +34,7 @@ const subscriptionCache: Map { - if (err) { - result.emit('error', err); - return; - } - + const subscription = watcher.subscribe(root, (_err, events) => { for (const event of events) { const relativePath = path.relative(root, event.path); @@ -62,8 +57,6 @@ function createWatcher(root: string): Stream { ] }); - subscription.catch(err => result.emit('error', err)); - // Cleanup on process exit const cleanup = () => { subscription.then(sub => sub.unsubscribe()).catch(() => { }); diff --git a/extensions/css-language-features/package-lock.json b/extensions/css-language-features/package-lock.json index 42656ea4bae20..abb2975f7de83 100644 --- a/extensions/css-language-features/package-lock.json +++ b/extensions/css-language-features/package-lock.json @@ -29,9 +29,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/html-language-features/package-lock.json b/extensions/html-language-features/package-lock.json index 442b79121ebb7..ab0ef3f9510e2 100644 --- a/extensions/html-language-features/package-lock.json +++ b/extensions/html-language-features/package-lock.json @@ -30,9 +30,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/json-language-features/package-lock.json b/extensions/json-language-features/package-lock.json index c7c66f40ccc77..57df1eea33719 100644 --- a/extensions/json-language-features/package-lock.json +++ b/extensions/json-language-features/package-lock.json @@ -30,9 +30,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" diff --git a/extensions/mermaid-chat-features/package.nls.json b/extensions/mermaid-chat-features/package.nls.json index d2fe3d44c3091..5f9977bfdb8d3 100644 --- a/extensions/mermaid-chat-features/package.nls.json +++ b/extensions/mermaid-chat-features/package.nls.json @@ -1,5 +1,5 @@ { "displayName": "Mermaid Chat Features", "description": "Adds Mermaid diagram support to built-in chats.", - "config.enabled.description": "Enable a tool for experimental Mermaid diagram rendering in chat responses." + "config.enabled.description": "Enable a tool for Mermaid diagram rendering in chat responses." } diff --git a/extensions/terminal-suggest/src/completions/azd.ts b/extensions/terminal-suggest/src/completions/azd.ts index 2a4adb84d62f4..6a4cf535ee793 100644 --- a/extensions/terminal-suggest/src/completions/azd.ts +++ b/extensions/terminal-suggest/src/completions/azd.ts @@ -27,6 +27,15 @@ interface AzdExtensionListItem { source: string; } +interface AzdConfigOption { + Key: string; + Description: string; + Type: string; + AllowedValues?: string[] | null; + Example?: string; + EnvVar?: string; +} + const azdGenerators: Record = { listEnvironments: { script: ['azd', 'env', 'list', '--output', 'json'], @@ -181,6 +190,25 @@ const azdGenerators: Record = { } }, }, + listConfigKeys: { + script: ['azd', 'config', 'options', '--output', 'json'], + postProcess: (out) => { + try { + const options: AzdConfigOption[] = JSON.parse(out); + return options + .filter((opt) => opt.Type !== 'envvar') // Exclude environment-only options + .map((opt) => ({ + name: opt.Key, + description: opt.Description, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, }; const completionSpec: Fig.Spec = { @@ -193,11 +221,534 @@ const completionSpec: Fig.Spec = { }, { name: ['ai'], - description: 'Extension for the Foundry Agent Service. (Preview)', + description: 'Commands for the ai extension namespace.', subcommands: [ { name: ['agent'], description: 'Extension for the Foundry Agent Service. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI agent project. (Preview)', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the azd environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--host'], + description: '[Optional] For container based agents, can override the default host to target a container app instead. Accepted values: \'containerapp\'', + args: [ + { + name: 'host', + }, + ], + }, + { + name: ['--manifest', '-m'], + description: 'Path or URI to an agent manifest to add to your azd project', + args: [ + { + name: 'manifest', + }, + ], + }, + { + name: ['--project-id', '-p'], + description: 'Existing Microsoft Foundry Project Id to initialize your azd environment with', + args: [ + { + name: 'project-id', + }, + ], + }, + { + name: ['--src', '-s'], + description: '[Optional] Directory to download the agent definition to (defaults to \'src/\')', + args: [ + { + name: 'src', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], + }, + { + name: ['finetuning'], + description: 'Extension for Foundry Fine Tuning. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI Fine-tuning project. (Preview)', + options: [ + { + name: ['--environment', '-n'], + description: 'The name of the azd environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--from-job', '-j'], + description: 'Clone configuration from an existing job ID', + args: [ + { + name: 'from-job', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--project-resource-id', '-p'], + description: 'ARM resource ID of the Microsoft Foundry Project (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{account}/projects/{project})', + args: [ + { + name: 'project-resource-id', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--template', '-t'], + description: 'URL or path to a fine-tune job template', + args: [ + { + name: 'template', + }, + ], + }, + { + name: ['--working-directory', '-w'], + description: 'Local path for project output', + args: [ + { + name: 'working-directory', + }, + ], + }, + ], + }, + { + name: ['jobs'], + description: 'Manage fine-tuning jobs', + subcommands: [ + { + name: ['cancel'], + description: 'Cancels a running or queued fine-tuning job.', + options: [ + { + name: ['--force'], + description: 'Skip confirmation prompt', + isDangerous: true, + }, + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['deploy'], + description: 'Deploy a fine-tuned model to Azure Cognitive Services', + options: [ + { + name: ['--capacity', '-c'], + description: 'Capacity units', + args: [ + { + name: 'capacity', + }, + ], + }, + { + name: ['--deployment-name', '-d'], + description: 'Deployment name (required)', + args: [ + { + name: 'deployment-name', + }, + ], + }, + { + name: ['--job-id', '-i'], + description: 'Fine-tuning job ID (required)', + args: [ + { + name: 'job-id', + }, + ], + }, + { + name: ['--model-format', '-m'], + description: 'Model format', + args: [ + { + name: 'model-format', + }, + ], + }, + { + name: ['--no-wait'], + description: 'Do not wait for deployment to complete', + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--sku', '-k'], + description: 'SKU for deployment', + args: [ + { + name: 'sku', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List fine-tuning jobs.', + options: [ + { + name: ['--after'], + description: 'Pagination cursor', + args: [ + { + name: 'after', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'Output format: table, json', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--top', '-t'], + description: 'Number of jobs to return', + args: [ + { + name: 'top', + }, + ], + }, + ], + }, + { + name: ['pause'], + description: 'Pauses a running fine-tuning job.', + options: [ + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['resume'], + description: 'Resumes a paused fine-tuning job.', + options: [ + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Shows detailed information about a specific job.', + options: [ + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--logs'], + description: 'Include recent training logs', + }, + { + name: ['--output', '-o'], + description: 'Output format: table, json, yaml', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['submit'], + description: 'Submit fine-tuning job.', + options: [ + { + name: ['--file', '-f'], + description: 'Path to the config file.', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--model', '-m'], + description: 'Base model to fine-tune. Overrides config file. Required if --file is not provided', + args: [ + { + name: 'model', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--seed', '-r'], + description: 'Random seed for reproducibility of the job. If a seed is not specified, one will be generated for you. Overrides config file.', + args: [ + { + name: 'seed', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--suffix', '-x'], + description: 'An optional string of up to 64 characters that will be added to your fine-tuned model name. Overrides config file.', + args: [ + { + name: 'suffix', + }, + ], + }, + { + name: ['--training-file', '-t'], + description: 'Training file ID or local path. Use \'local:\' prefix for local paths. Required if --file is not provided', + args: [ + { + name: 'training-file', + }, + ], + }, + { + name: ['--validation-file', '-v'], + description: 'Validation file ID or local path. Use \'local:\' prefix for local paths.', + args: [ + { + name: 'validation-file', + }, + ], + }, + ], + }, + ], + options: [ + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, ], }, @@ -282,11 +833,73 @@ const completionSpec: Fig.Spec = { name: ['logout'], description: 'Log out of Azure.', }, + { + name: ['status'], + description: 'Show the current authentication status.', + }, ], }, { name: ['coding-agent'], description: 'This extension configures GitHub Copilot Coding Agent access to Azure', + subcommands: [ + { + name: ['config'], + description: 'Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP', + options: [ + { + name: ['--branch-name'], + description: 'The branch name to use when pushing changes to the copilot-setup-steps.yml', + args: [ + { + name: 'branch-name', + }, + ], + }, + { + name: ['--github-host-name'], + description: 'The hostname to use with GitHub commands', + args: [ + { + name: 'github-host-name', + }, + ], + }, + { + name: ['--managed-identity-name'], + description: 'The name to use for the managed identity, if created.', + args: [ + { + name: 'managed-identity-name', + }, + ], + }, + { + name: ['--remote-name'], + description: 'The name of the git remote where the Copilot Coding Agent will run (ex: /)', + args: [ + { + name: 'remote-name', + }, + ], + }, + { + name: ['--roles'], + description: 'The roles to assign to the service principal or managed identity. By default, the service principal or managed identity will be granted the Reader role.', + isRepeatable: true, + args: [ + { + name: 'roles', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, { name: ['completion'], @@ -314,6 +927,20 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['concurx'], + description: 'Concurrent execution for azd deployment', + subcommands: [ + { + name: ['up'], + description: 'Runs azd up in concurrent mode', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], + }, { name: ['config'], description: 'Manage azd configurations (ex: default Azure subscription, location).', @@ -323,12 +950,17 @@ const completionSpec: Fig.Spec = { description: 'Gets a configuration.', args: { name: 'path', + generators: azdGenerators.listConfigKeys, }, }, { name: ['list-alpha'], description: 'Display the list of available features in alpha stage.', }, + { + name: ['options'], + description: 'List all available configuration settings.', + }, { name: ['reset'], description: 'Resets configuration to default.', @@ -346,6 +978,7 @@ const completionSpec: Fig.Spec = { args: [ { name: 'path', + generators: azdGenerators.listConfigKeys, }, { name: 'value', @@ -361,6 +994,7 @@ const completionSpec: Fig.Spec = { description: 'Unsets a configuration.', args: { name: 'path', + generators: azdGenerators.listConfigKeys, }, }, ], @@ -368,6 +1002,46 @@ const completionSpec: Fig.Spec = { { name: ['demo'], description: 'This extension provides examples of the AZD extension framework.', + subcommands: [ + { + name: ['colors', 'colours'], + description: 'Displays all ASCII colors with their standard and high-intensity variants.', + }, + { + name: ['config'], + description: 'Set up monitoring configuration for the project and services', + }, + { + name: ['context'], + description: 'Get the context of the AZD project & environment.', + }, + { + name: ['gh-url-parse'], + description: 'Parse a GitHub URL and extract repository information.', + }, + { + name: ['listen'], + description: 'Starts the extension and listens for events.', + }, + { + name: ['mcp'], + description: 'MCP server commands for demo extension', + subcommands: [ + { + name: ['start'], + description: 'Start MCP server with demo tools', + }, + ], + }, + { + name: ['prompt'], + description: 'Examples of prompting the user for input.', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, { name: ['deploy'], @@ -410,30 +1084,95 @@ const completionSpec: Fig.Spec = { description: 'The name of the environment to use.', args: [ { - name: 'environment', + name: 'environment', + }, + ], + }, + { + name: ['--force'], + description: 'Does not require confirmation before it deletes resources.', + isDangerous: true, + }, + { + name: ['--purge'], + description: 'Does not require confirmation before it permanently deletes resources that are soft-deleted by default (for example, key vaults).', + isDangerous: true, + }, + ], + args: { + name: 'layer', + isOptional: true, + }, + }, + { + name: ['env'], + description: 'Manage environments (ex: default environment, environment variables).', + subcommands: [ + { + name: ['config'], + description: 'Manage environment configuration (ex: stored in .azure//config.json).', + subcommands: [ + { + name: ['get'], + description: 'Gets a configuration value from the environment.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: { + name: 'path', + }, + }, + { + name: ['set'], + description: 'Sets a configuration value in the environment.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: [ + { + name: 'path', + }, + { + name: 'value', + }, + ], + }, + { + name: ['unset'], + description: 'Unsets a configuration value in the environment.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: { + name: 'path', + }, }, ], }, - { - name: ['--force'], - description: 'Does not require confirmation before it deletes resources.', - isDangerous: true, - }, - { - name: ['--purge'], - description: 'Does not require confirmation before it permanently deletes resources that are soft-deleted by default (for example, key vaults).', - isDangerous: true, - }, - ], - args: { - name: 'layer', - isOptional: true, - }, - }, - { - name: ['env'], - description: 'Manage environments (ex: default environment, environment variables).', - subcommands: [ { name: ['get-value'], description: 'Get specific environment value.', @@ -535,11 +1274,35 @@ const completionSpec: Fig.Spec = { name: 'environment', }, }, + { + name: ['remove', 'rm'], + description: 'Remove an environment.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--force'], + description: 'Skips confirmation before performing removal.', + isDangerous: true, + }, + ], + args: { + name: 'environment', + }, + }, { name: ['select'], description: 'Set the default environment.', args: { name: 'environment', + isOptional: true, generators: azdGenerators.listEnvironments, }, }, @@ -607,7 +1370,7 @@ const completionSpec: Fig.Spec = { options: [ { name: ['--force', '-f'], - description: 'Force installation even if it would downgrade the current version', + description: 'Force installation, including downgrades and reinstalls', isDangerous: true, }, { @@ -1383,137 +2146,370 @@ const completionSpec: Fig.Spec = { description: 'The name of the environment to use.', args: [ { - name: 'environment', + name: 'environment', + }, + ], + }, + { + name: ['--show-secrets'], + description: 'Unmask secrets in output.', + isDangerous: true, + }, + ], + args: { + name: 'resource-name|resource-id', + isOptional: true, + }, + }, + { + name: ['template'], + description: 'Find and view template details.', + subcommands: [ + { + name: ['list', 'ls'], + description: 'Show list of sample azd templates. (Beta)', + options: [ + { + name: ['--filter', '-f'], + description: 'The tag(s) used to filter template results. Supports comma-separated values.', + isRepeatable: true, + args: [ + { + name: 'filter', + generators: azdGenerators.listTemplateTags, + }, + ], + }, + { + name: ['--source', '-s'], + description: 'Filters templates by source.', + args: [ + { + name: 'source', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Show details for a given template. (Beta)', + args: { + name: 'template', + generators: azdGenerators.listTemplates, + }, + }, + { + name: ['source'], + description: 'View and manage template sources. (Beta)', + subcommands: [ + { + name: ['add'], + description: 'Adds an azd template source with the specified key. (Beta)', + options: [ + { + name: ['--location', '-l'], + description: 'Location of the template source. Required when using type flag.', + args: [ + { + name: 'location', + }, + ], + }, + { + name: ['--name', '-n'], + description: 'Display name of the template source.', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--type', '-t'], + description: 'Kind of the template source. Supported types are \'file\', \'url\' and \'gh\'.', + args: [ + { + name: 'type', + }, + ], + }, + ], + args: { + name: 'key', + }, + }, + { + name: ['list', 'ls'], + description: 'Lists the configured azd template sources. (Beta)', + }, + { + name: ['remove'], + description: 'Removes the specified azd template source (Beta)', + args: { + name: 'key', + }, + }, + ], + }, + ], + }, + { + name: ['up'], + description: 'Provision and deploy your project to Azure with a single command.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Print the version number of Azure Developer CLI.', + }, + { + name: ['x'], + description: 'This extension provides a set of tools for AZD extension developers to test and debug their extensions.', + subcommands: [ + { + name: ['build'], + description: 'Build the azd extension project', + options: [ + { + name: ['--all'], + description: 'When set builds for all os/platforms. Defaults to the current os/platform only.', + }, + { + name: ['--output', '-o'], + description: 'Path to the output directory. Defaults to ./bin folder.', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--skip-install'], + description: 'When set skips reinstalling extension after successful build.', + }, + ], + }, + { + name: ['init'], + description: 'Initialize a new AZD extension project', + options: [ + { + name: ['--capabilities'], + description: 'The list of capabilities for the extension (e.g., custom-commands,lifecycle-events,mcp-server,service-target-provider).', + isRepeatable: true, + args: [ + { + name: 'capabilities', + }, + ], + }, + { + name: ['--id'], + description: 'The extension identifier (e.g., company.extension).', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--language'], + description: 'The programming language for the extension (go, dotnet, javascript, python).', + args: [ + { + name: 'language', + }, + ], + }, + { + name: ['--name'], + description: 'The display name for the extension.', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--namespace'], + description: 'The namespace for the extension commands.', + args: [ + { + name: 'namespace', + }, + ], + }, + { + name: ['--registry', '-r'], + description: 'When set will create a local extension source registry.', + }, + ], + }, + { + name: ['pack'], + description: 'Build and pack extension artifacts', + options: [ + { + name: ['--input', '-i'], + description: 'Path to the input directory.', + args: [ + { + name: 'input', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'Path to the artifacts output directory. If not provided, will use local registry artifacts path.', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--rebuild'], + description: 'Rebuild the extension before packaging.', }, ], }, { - name: ['--show-secrets'], - description: 'Unmask secrets in output.', - isDangerous: true, - }, - ], - args: { - name: 'resource-name|resource-id', - isOptional: true, - }, - }, - { - name: ['template'], - description: 'Find and view template details.', - subcommands: [ - { - name: ['list', 'ls'], - description: 'Show list of sample azd templates. (Beta)', + name: ['publish'], + description: 'Publish the extension to the extension source', options: [ { - name: ['--filter', '-f'], - description: 'The tag(s) used to filter template results. Supports comma-separated values.', + name: ['--artifacts'], + description: 'Path to artifacts to process (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', isRepeatable: true, args: [ { - name: 'filter', - generators: azdGenerators.listTemplateTags, + name: 'artifacts', }, ], }, { - name: ['--source', '-s'], - description: 'Filters templates by source.', + name: ['--registry', '-r'], + description: 'Path to the extension source registry', args: [ { - name: 'source', + name: 'registry', + }, + ], + }, + { + name: ['--repo'], + description: 'GitHub repository to create the release in (e.g. owner/repo)', + args: [ + { + name: 'repo', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'Version of the release', + args: [ + { + name: 'version', }, ], }, ], }, { - name: ['show'], - description: 'Show details for a given template. (Beta)', - args: { - name: 'template', - generators: azdGenerators.listTemplates, - }, - }, - { - name: ['source'], - description: 'View and manage template sources. (Beta)', - subcommands: [ + name: ['release'], + description: 'Create a new extension release from the packaged artifacts', + options: [ { - name: ['add'], - description: 'Adds an azd template source with the specified key. (Beta)', - options: [ + name: ['--artifacts'], + description: 'Path to artifacts to upload to the release (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', + isRepeatable: true, + args: [ { - name: ['--location', '-l'], - description: 'Location of the template source. Required when using type flag.', - args: [ - { - name: 'location', - }, - ], + name: 'artifacts', }, + ], + }, + { + name: ['--confirm'], + description: 'Skip confirmation prompt', + }, + { + name: ['--draft', '-d'], + description: 'Create a draft release', + }, + { + name: ['--notes', '-n'], + description: 'Release notes', + args: [ { - name: ['--name', '-n'], - description: 'Display name of the template source.', - args: [ - { - name: 'name', - }, - ], + name: 'notes', }, + ], + }, + { + name: ['--notes-file', '-F'], + description: 'Read release notes from file (use "-" to read from standard input)', + args: [ { - name: ['--type', '-t'], - description: 'Kind of the template source. Supported types are \'file\', \'url\' and \'gh\'.', - args: [ - { - name: 'type', - }, - ], + name: 'notes-file', }, ], - args: { - name: 'key', - }, }, { - name: ['list', 'ls'], - description: 'Lists the configured azd template sources. (Beta)', + name: ['--prerelease'], + description: 'Create a pre-release version', }, { - name: ['remove'], - description: 'Removes the specified azd template source (Beta)', - args: { - name: 'key', - }, + name: ['--repo', '-r'], + description: 'GitHub repository to create the release in (e.g. owner/repo)', + args: [ + { + name: 'repo', + }, + ], }, - ], - }, - ], - }, - { - name: ['up'], - description: 'Provision and deploy your project to Azure with a single command.', - options: [ - { - name: ['--environment', '-e'], - description: 'The name of the environment to use.', - args: [ { - name: 'environment', + name: ['--title', '-t'], + description: 'Title of the release', + args: [ + { + name: 'title', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'Version of the release', + args: [ + { + name: 'version', + }, + ], }, ], }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + { + name: ['watch'], + description: 'Watches the AZD extension project for file changes and rebuilds it.', + }, ], }, - { - name: ['version'], - description: 'Print the version number of Azure Developer CLI.', - }, - { - name: ['x'], - description: 'This extension provides a set of tools for AZD extension developers to test and debug their extensions.', - }, { name: ['help'], description: 'Help about any command', @@ -1524,11 +2520,69 @@ const completionSpec: Fig.Spec = { }, { name: ['ai'], - description: 'Extension for the Foundry Agent Service. (Preview)', + description: 'Commands for the ai extension namespace.', subcommands: [ { name: ['agent'], description: 'Extension for the Foundry Agent Service. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI agent project. (Preview)', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], + }, + { + name: ['finetuning'], + description: 'Extension for Foundry Fine Tuning. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI Fine-tuning project. (Preview)', + }, + { + name: ['jobs'], + description: 'Manage fine-tuning jobs', + subcommands: [ + { + name: ['cancel'], + description: 'Cancels a running or queued fine-tuning job.', + }, + { + name: ['deploy'], + description: 'Deploy a fine-tuned model to Azure Cognitive Services', + }, + { + name: ['list'], + description: 'List fine-tuning jobs.', + }, + { + name: ['pause'], + description: 'Pauses a running fine-tuning job.', + }, + { + name: ['resume'], + description: 'Resumes a paused fine-tuning job.', + }, + { + name: ['show'], + description: 'Shows detailed information about a specific job.', + }, + { + name: ['submit'], + description: 'Submit fine-tuning job.', + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, ], }, @@ -1544,11 +2598,25 @@ const completionSpec: Fig.Spec = { name: ['logout'], description: 'Log out of Azure.', }, + { + name: ['status'], + description: 'Show the current authentication status.', + }, ], }, { name: ['coding-agent'], description: 'This extension configures GitHub Copilot Coding Agent access to Azure', + subcommands: [ + { + name: ['config'], + description: 'Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, { name: ['completion'], @@ -1576,6 +2644,20 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['concurx'], + description: 'Concurrent execution for azd deployment', + subcommands: [ + { + name: ['up'], + description: 'Runs azd up in concurrent mode', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], + }, { name: ['config'], description: 'Manage azd configurations (ex: default Azure subscription, location).', @@ -1588,6 +2670,10 @@ const completionSpec: Fig.Spec = { name: ['list-alpha'], description: 'Display the list of available features in alpha stage.', }, + { + name: ['options'], + description: 'List all available configuration settings.', + }, { name: ['reset'], description: 'Resets configuration to default.', @@ -1609,6 +2695,46 @@ const completionSpec: Fig.Spec = { { name: ['demo'], description: 'This extension provides examples of the AZD extension framework.', + subcommands: [ + { + name: ['colors', 'colours'], + description: 'Displays all ASCII colors with their standard and high-intensity variants.', + }, + { + name: ['config'], + description: 'Set up monitoring configuration for the project and services', + }, + { + name: ['context'], + description: 'Get the context of the AZD project & environment.', + }, + { + name: ['gh-url-parse'], + description: 'Parse a GitHub URL and extract repository information.', + }, + { + name: ['listen'], + description: 'Starts the extension and listens for events.', + }, + { + name: ['mcp'], + description: 'MCP server commands for demo extension', + subcommands: [ + { + name: ['start'], + description: 'Start MCP server with demo tools', + }, + ], + }, + { + name: ['prompt'], + description: 'Examples of prompting the user for input.', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, { name: ['deploy'], @@ -1622,6 +2748,24 @@ const completionSpec: Fig.Spec = { name: ['env'], description: 'Manage environments (ex: default environment, environment variables).', subcommands: [ + { + name: ['config'], + description: 'Manage environment configuration (ex: stored in .azure//config.json).', + subcommands: [ + { + name: ['get'], + description: 'Gets a configuration value from the environment.', + }, + { + name: ['set'], + description: 'Sets a configuration value in the environment.', + }, + { + name: ['unset'], + description: 'Unsets a configuration value in the environment.', + }, + ], + }, { name: ['get-value'], description: 'Get specific environment value.', @@ -1642,6 +2786,10 @@ const completionSpec: Fig.Spec = { name: ['refresh'], description: 'Refresh environment values by using information from a previous infrastructure provision.', }, + { + name: ['remove', 'rm'], + description: 'Remove an environment.', + }, { name: ['select'], description: 'Set the default environment.', @@ -1829,6 +2977,36 @@ const completionSpec: Fig.Spec = { { name: ['x'], description: 'This extension provides a set of tools for AZD extension developers to test and debug their extensions.', + subcommands: [ + { + name: ['build'], + description: 'Build the azd extension project', + }, + { + name: ['init'], + description: 'Initialize a new AZD extension project', + }, + { + name: ['pack'], + description: 'Build and pack extension artifacts', + }, + { + name: ['publish'], + description: 'Publish the extension to the extension source', + }, + { + name: ['release'], + description: 'Create a new extension release from the packaged artifacts', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + { + name: ['watch'], + description: 'Watches the AZD extension project for file changes and rebuilds it.', + }, + ], }, ], }, diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf new file mode 100644 index 0000000000000..a3098e1715c52 Binary files /dev/null and b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf differ diff --git a/src/vs/editor/common/model/tokens/annotations.ts b/src/vs/editor/common/model/tokens/annotations.ts index 57a2c45fc48d6..61b660114366f 100644 --- a/src/vs/editor/common/model/tokens/annotations.ts +++ b/src/vs/editor/common/model/tokens/annotations.ts @@ -82,6 +82,11 @@ export class AnnotatedString implements IAnnotatedString { let startIndex: number; if (startIndexWhereToReplace >= 0) { startIndex = startIndexWhereToReplace; + // Also include the next annotation if it ends exactly at offset (touching boundary) + const nextCandidate = this._annotations[startIndex]?.range; + if (nextCandidate && nextCandidate.endExclusive === offset) { + startIndex--; + } } else { const candidate = this._annotations[- (startIndexWhereToReplace + 2)]?.range; if (candidate && offset >= candidate.start && offset < candidate.endExclusive) { @@ -101,6 +106,11 @@ export class AnnotatedString implements IAnnotatedString { let endIndexExclusive: number; if (endIndexWhereToReplace >= 0) { endIndexExclusive = endIndexWhereToReplace + 1; + // Also include the next annotation if it starts exactly at offset (touching boundary) + const nextCandidate = this._annotations[endIndexExclusive]?.range; + if (nextCandidate && nextCandidate.start === offset) { + endIndexExclusive++; + } } else { const candidate = this._annotations[-(endIndexWhereToReplace + 1)]?.range; if (candidate && offset >= candidate.start && offset <= candidate.endExclusive) { diff --git a/src/vs/editor/test/common/model/annotations.test.ts b/src/vs/editor/test/common/model/annotations.test.ts index 3e816f3607bd1..5b614da4d47ee 100644 --- a/src/vs/editor/test/common/model/annotations.test.ts +++ b/src/vs/editor/test/common/model/annotations.test.ts @@ -381,6 +381,13 @@ suite('Annotations Suite', () => { assert.deepStrictEqual(result.map(a => a.annotation), ['1', '2', '3']); }); + test('getAnnotationsIntersecting 6', () => { + const vas = fromVisual('[1:Lorem ][2:ip][3:sum]'); + const result = vas.getAnnotationsIntersecting(new OffsetRange(6, 6)); + assert.strictEqual(result.length, 1); + assert.deepStrictEqual(result.map(a => a.annotation), ['2']); + }); + test('applyEdit 1 - deletion within annotation', () => { const result = visualizeEdit( '[1:Lorem] ipsum [2:dolor] sit [3:amet]', diff --git a/src/vs/platform/telemetry/common/telemetryUtils.ts b/src/vs/platform/telemetry/common/telemetryUtils.ts index fffdb5cd95efd..7c5c89eae0886 100644 --- a/src/vs/platform/telemetry/common/telemetryUtils.ts +++ b/src/vs/platform/telemetry/common/telemetryUtils.ts @@ -310,6 +310,11 @@ function anonymizeFilePaths(stack: string, cleanupPatterns: RegExp[]): string { } const nodeModulesRegex = /^[\\\/]?(node_modules|node_modules\.asar)[\\\/]/; + // Match VS Code extension paths: + // 1. User extensions: .vscode/extensions/, .vscode-insiders/extensions/, .vscode-server/extensions/, .vscode-server-insiders/extensions/, etc. + // 2. Built-in extensions: resources/app/extensions/ + // Capture everything from the vscode folder or resources/app/extensions onwards + const vscodeExtensionsPathRegex = /^(.*?)((?:\.vscode(?:-[a-z]+)*|resources[\\\/]app)[\\\/]extensions[\\\/].*)$/i; const fileRegex = /(file:\/\/)?([a-zA-Z]:(\\\\|\\|\/)|(\\\\|\\|\/))?([\w-\._]+(\\\\|\\|\/))+[\w-\._]*/g; let lastIndex = 0; updatedStack = ''; @@ -325,7 +330,14 @@ function anonymizeFilePaths(stack: string, cleanupPatterns: RegExp[]): string { // anoynimize user file paths that do not need to be retained or cleaned up. if (!nodeModulesRegex.test(result[0]) && !overlappingRange) { - updatedStack += stack.substring(lastIndex, result.index) + ''; + // Check if this is a VS Code extension path - if so, preserve the .vscode*/extensions/... portion + const vscodeExtMatch = vscodeExtensionsPathRegex.exec(result[0]); + if (vscodeExtMatch) { + // Keep ".vscode[-variant]/extensions/extension-name/..." but redact the parent folder + updatedStack += stack.substring(lastIndex, result.index) + '/' + vscodeExtMatch[2]; + } else { + updatedStack += stack.substring(lastIndex, result.index) + ''; + } lastIndex = fileRegex.lastIndex; } } diff --git a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts index d9e00af00b0fb..d6a1b2370d511 100644 --- a/src/vs/platform/telemetry/test/browser/telemetryService.test.ts +++ b/src/vs/platform/telemetry/test/browser/telemetryService.test.ts @@ -58,6 +58,15 @@ class ErrorTestingSettings { public anonymizedRandomUserFile: string = ''; public nodeModulePathToRetain: string = 'node_modules/path/that/shouldbe/retained/names.js:14:15854'; public nodeModuleAsarPathToRetain: string = 'node_modules.asar/path/that/shouldbe/retained/names.js:14:12354'; + public extensionPathToRetain: string = '.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; + public fullExtensionPath: string = '/Users/username/.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; + public anonymizedExtensionPath: string = '/.vscode/extensions/ms-python.python-2024.0.1/out/extension.js:144:145516'; + public serverInsidersExtensionPathToRetain: string = '.vscode-server-insiders/extensions/ms-vscode.remote-server-2024.1.0/out/server.js:99:8888'; + public fullServerInsidersExtensionPath: string = '/home/user/.vscode-server-insiders/extensions/ms-vscode.remote-server-2024.1.0/out/server.js:99:8888'; + public anonymizedServerInsidersExtensionPath: string = '/.vscode-server-insiders/extensions/ms-vscode.remote-server-2024.1.0/out/server.js:99:8888'; + public builtinExtensionPathToRetain: string = 'Resources/app/extensions/git/out/git.js:42:1234'; + public fullBuiltinExtensionPath: string = '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/git/out/git.js:42:1234'; + public anonymizedBuiltinExtensionPath: string = '/Resources/app/extensions/git/out/git.js:42:1234'; constructor() { this.personalInfo = 'DANGEROUS/PATH'; @@ -81,6 +90,9 @@ class ErrorTestingSettings { ` at t._handleMessage (${this.nodeModuleAsarPathToRetain})`, ` at t._onmessage (/${this.nodeModulePathToRetain})`, ` at t.onmessage (${this.nodeModulePathToRetain})`, + ` at uv.provideCodeActions (${this.fullExtensionPath})`, + ` at remote.handleConnection (${this.fullServerInsidersExtensionPath})`, + ` at git.getRepositoryState (${this.fullBuiltinExtensionPath})`, ` at DedicatedWorkerGlobalScope.self.onmessage`, this.dangerousPathWithImportantInfo, this.dangerousPathWithoutImportantInfo, @@ -505,6 +517,49 @@ suite('TelemetryService', () => { } })); + test('Unexpected Error Telemetry removes PII but preserves extension path', sinonTestFn(function (this: any) { + + const origErrorHandler = Errors.errorHandler.getUnexpectedErrorHandler(); + Errors.setUnexpectedErrorHandler(() => { }); + + try { + const settings = new ErrorTestingSettings(); + const testAppender = new TestTelemetryAppender(); + const service = new TestErrorTelemetryService({ appenders: [testAppender] }); + const errorTelemetry = new ErrorTelemetry(service); + + const dangerousPathWithImportantInfoError: any = new Error(settings.dangerousPathWithImportantInfo); + dangerousPathWithImportantInfoError.stack = settings.stack; + + Errors.onUnexpectedError(dangerousPathWithImportantInfoError); + this.clock.tick(ErrorTelemetry.ERROR_FLUSH_TIMEOUT); + + // Verify user extension path is preserved but parent folder is redacted + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.extensionPathToRetain), -1, 'User extension path should be retained'); + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.anonymizedExtensionPath), -1, 'User extension path should be anonymized with preserved extension name'); + // Verify the username is removed + assert.strictEqual(testAppender.events[0].data.callstack.indexOf('/Users/username/'), -1, 'Username should be redacted from extension path'); + + // Verify server-insiders extension path is preserved (multi-segment suffix like .vscode-server-insiders) + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.serverInsidersExtensionPathToRetain), -1, 'Server-insiders extension path should be retained'); + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.anonymizedServerInsidersExtensionPath), -1, 'Server-insiders extension path should be anonymized with preserved extension name'); + // Verify the home directory is removed + assert.strictEqual(testAppender.events[0].data.callstack.indexOf('/home/user/'), -1, 'Home directory should be redacted from server-insiders extension path'); + + // Verify built-in extension path is preserved but app folder is redacted + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.builtinExtensionPathToRetain), -1, 'Built-in extension path should be retained'); + assert.notStrictEqual(testAppender.events[0].data.callstack.indexOf(settings.anonymizedBuiltinExtensionPath), -1, 'Built-in extension path should be anonymized with preserved extension name'); + // Verify the app path is removed + assert.strictEqual(testAppender.events[0].data.callstack.indexOf('/Applications/Visual Studio Code.app'), -1, 'App path should be redacted from built-in extension path'); + + errorTelemetry.dispose(); + service.dispose(); + } + finally { + Errors.setUnexpectedErrorHandler(origErrorHandler); + } + })); + test('Unexpected Error Telemetry removes PII but preserves Code file path when PIIPath is configured', sinonTestFn(function (this: any) { const origErrorHandler = Errors.errorHandler.getUnexpectedErrorHandler(); diff --git a/src/vs/platform/workspace/test/common/testWorkspace.ts b/src/vs/platform/workspace/test/common/testWorkspace.ts index 81fee382320e0..9c1be0e54d28e 100644 --- a/src/vs/platform/workspace/test/common/testWorkspace.ts +++ b/src/vs/platform/workspace/test/common/testWorkspace.ts @@ -21,6 +21,6 @@ export class Workspace extends BaseWorkspace { const wsUri = URI.file(isWindows ? 'C:\\testWorkspace' : '/testWorkspace'); export const TestWorkspace = testWorkspace(wsUri); -export function testWorkspace(resource: URI): Workspace { - return new Workspace(resource.toString(), [toWorkspaceFolder(resource)]); +export function testWorkspace(...resource: URI[]): Workspace { + return new Workspace('test-workspace', resource.map(toWorkspaceFolder)); } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 316a4e5606f07..ba3b0487d381e 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -2961,6 +2961,18 @@ export namespace ChatToolInvocationPart { status: todoStatusEnumToString(todo.status) })) }; + } else if (data && 'values' in data && Array.isArray(data.values)) { + // Convert extension API resources tool data to internal format + return { + kind: 'resources', + values: data.values.map((v: any) => { + if (v instanceof types.Location) { + return Location.from(v); + } else { + return URI.revive(v); + } + }) + }; } return data; } diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index c4866fed119b5..5693d5292639e 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -16,7 +16,7 @@ import { ServicesAccessor } from '../../../../../platform/instantiation/common/i import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { migrateLegacyTerminalToolSpecificData } from '../../common/chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, ILegacyChatTerminalToolInvocationData, IToolResultOutputDetailsSerialized, isLegacyChatTerminalToolInvocationData } from '../../common/chatService/chatService.js'; +import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolResourcesInvocationData, ILegacyChatTerminalToolInvocationData, IToolResultOutputDetailsSerialized, isLegacyChatTerminalToolInvocationData } from '../../common/chatService/chatService.js'; import { isResponseVM } from '../../common/model/chatViewModel.js'; import { IToolResultInputOutputDetails, IToolResultOutputDetails, isToolResultInputOutputDetails, isToolResultOutputDetails, toolContentToA11yString } from '../../common/tools/languageModelToolsService.js'; import { ChatTreeItem, IChatWidget, IChatWidgetService } from '../chat.js'; @@ -48,7 +48,7 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation } } -type ToolSpecificData = IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; +type ToolSpecificData = IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatToolResourcesInvocationData; type ResultDetails = Array | IToolResultInputOutputDetails | IToolResultOutputDetails | IToolResultOutputDetailsSerialized; function isOutputDetailsSerialized(obj: unknown): obj is IToolResultOutputDetailsSerialized { @@ -102,6 +102,22 @@ export function getToolSpecificDataDescription(toolSpecificData: ToolSpecificDat return typeof toolSpecificData.rawInput === 'string' ? toolSpecificData.rawInput : JSON.stringify(toolSpecificData.rawInput); + case 'resources': { + const values = toolSpecificData.values; + if (values.length === 0) { + return ''; + } + const paths = values.map(v => { + if ('uri' in v && 'range' in v) { + // Location + return `${v.uri.fsPath || v.uri.path}:${v.range.startLineNumber}`; + } else { + // URI + return v.fsPath || v.path; + } + }).join(', '); + return localize('resourcesList', "Resources: {0}", paths); + } default: return ''; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index b82569c057a24..a733bc3defb51 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -106,9 +106,11 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age content.push(localize('chatAgent.autoApprove', 'To automatically approve tool actions without manual confirmation, set {0} to {1} in your settings.', ChatConfiguration.GlobalAutoApprove, 'true')); content.push(localize('chatAgent.acceptTool', 'To accept a tool action, use the Accept Tool Confirmation command{0}.', '')); content.push(localize('chatAgent.openEditedFilesSetting', 'By default, when edits are made to files, they will be opened. To change this behavior, set accessibility.openChatEditedFiles to false in your settings.')); + content.push(localize('chatAgent.focusTodosView', 'To toggle focus between the Agent TODOs view and the chat input, use Agent TODOs: Toggle Focus{0}.', '')); } content.push(localize('chatEditing.helpfulCommands', 'Some helpful commands include:')); content.push(localize('workbench.action.chat.undoEdits', '- Undo Edits{0}.', '')); + content.push(localize('workbench.action.chat.restoreLastCheckpoint', '- Restore to Last Checkpoint{0}.', '')); content.push(localize('workbench.action.chat.editing.attachFiles', '- Attach Files{0}.', '')); content.push(localize('chatEditing.removeFileFromWorkingSet', '- Remove File from Working Set{0}.', '')); content.push(localize('chatEditing.acceptFile', '- Keep{0} and Undo File{1}.', '', '')); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 7b64c28cbbdbb..3898260d3ebb0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { isAncestorOfActiveElement } from '../../../../../base/browser/dom.js'; +import { alert } from '../../../../../base/browser/ui/aria/aria.js'; import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; import { coalesce } from '../../../../../base/common/arrays.js'; import { timeout } from '../../../../../base/common/async.js'; @@ -748,6 +749,37 @@ export function registerChatActions() { } }); + registerAction2(class FocusTodosViewAction extends Action2 { + static readonly ID = 'workbench.action.chat.focusTodosView'; + + constructor() { + super({ + id: FocusTodosViewAction.ID, + title: localize2('interactiveSession.focusTodosView.label', "Agent TODOs: Toggle Focus Between TODOs and Input"), + category: CHAT_CATEGORY, + f1: true, + precondition: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent)), + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyT, + when: ContextKeyExpr.or( + ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent)), + ChatContextKeys.inChatTodoList + ), + }] + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + + if (!widget || !widget.toggleTodosViewFocus()) { + alert(localize('chat.todoList.focusUnavailable', "No agent todos to focus right now.")); + } + } + }); + const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.provider.enterprise.id)); registerAction2(class extends Action2 { constructor() { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts index a5b99a144ddaf..533761a59011a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts @@ -10,7 +10,7 @@ import { localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IPromptsService, PromptsStorage, IPromptFileDiscoveryResult, PromptFileSkipReason } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage, IPromptFileDiscoveryResult, PromptFileSkipReason, AgentFileType } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsConfig } from '../../common/promptSyntax/config/config.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { basename, dirname, relativePath } from '../../../../../base/common/resources.js'; @@ -24,6 +24,9 @@ import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; import { ChatViewId } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { parseAllHookFiles, IParsedHook } from '../promptSyntax/hookUtils.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; /** * URL encodes path segments for use in markdown links. @@ -103,6 +106,8 @@ export interface ITypeStatusInfo { paths: IPathInfo[]; files: IFileStatusInfo[]; enabled: boolean; + /** For hooks only: parsed hooks grouped by lifecycle */ + parsedHooks?: IParsedHook[]; } /** @@ -141,9 +146,11 @@ export function registerChatCustomizationDiagnosticsAction() { const untitledTextEditorService = accessor.get(IUntitledTextEditorService); const commandService = accessor.get(ICommandService); const workspaceContextService = accessor.get(IWorkspaceContextService); + const labelService = accessor.get(ILabelService); const token = CancellationToken.None; const workspaceFolders = workspaceContextService.getWorkspace().folders; + const pathService = accessor.get(IPathService); // Collect status for each type const statusInfos: ITypeStatusInfo[] = []; @@ -164,7 +171,11 @@ export function registerChatCustomizationDiagnosticsAction() { const skillsStatus = await collectSkillsStatus(promptsService, configurationService, fileService, token); statusInfos.push(skillsStatus); - // 5. Special files (AGENTS.md, copilot-instructions.md) + // 5. Hooks + const hooksStatus = await collectHooksStatus(promptsService, fileService, labelService, pathService, workspaceContextService, token); + statusInfos.push(hooksStatus); + + // 6. Special files (AGENTS.md, copilot-instructions.md) const specialFilesStatus = await collectSpecialFilesStatus(promptsService, configurationService, token); // Generate the markdown output @@ -274,6 +285,61 @@ async function collectSkillsStatus( return { type, paths, files, enabled }; } +export interface ISpecialFilesStatus { + agentsMd: { enabled: boolean; files: URI[] }; + copilotInstructions: { enabled: boolean; files: URI[] }; + claudeMd: { enabled: boolean; files: URI[] }; +} + +/** + * Collects status for hook files. + */ +async function collectHooksStatus( + promptsService: IPromptsService, + fileService: IFileService, + labelService: ILabelService, + pathService: IPathService, + workspaceContextService: IWorkspaceContextService, + token: CancellationToken +): Promise { + const type = PromptsType.hook; + const enabled = true; // Hooks are always enabled + + // Get resolved source folders using the shared path resolution logic + const resolvedFolders = await promptsService.getResolvedSourceFolders(type); + const paths = await convertResolvedFoldersToPathInfo(resolvedFolders, fileService); + + // Get discovery info from the service (handles all duplicate detection and error tracking) + const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token); + const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus); + + // Parse hook files to extract individual hooks grouped by lifecycle + const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, token); + + return { type, paths, files, enabled, parsedHooks }; +} + +/** + * Parses all hook files and extracts individual hooks. + */ +async function parseHookFiles( + promptsService: IPromptsService, + fileService: IFileService, + labelService: ILabelService, + pathService: IPathService, + workspaceContextService: IWorkspaceContextService, + token: CancellationToken +): Promise { + // Get workspace root and user home for path resolution + const workspaceFolder = workspaceContextService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + const userHomeUri = await pathService.userHome(); + const userHome = userHomeUri.fsPath ?? userHomeUri.path; + + // Use the shared helper + return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, token); +} + /** * Collects status for special files like AGENTS.md and copilot-instructions.md. */ @@ -281,24 +347,26 @@ async function collectSpecialFilesStatus( promptsService: IPromptsService, configurationService: IConfigurationService, token: CancellationToken -): Promise<{ agentsMd: { enabled: boolean; files: URI[] }; copilotInstructions: { enabled: boolean; files: URI[] } }> { - // AGENTS.md +): Promise { const useAgentMd = configurationService.getValue(PromptsConfig.USE_AGENT_MD) ?? false; - let agentMdFiles: URI[] = []; - if (useAgentMd) { - agentMdFiles = await promptsService.listAgentMDs(token, false); - } - - // copilot-instructions.md + const useClaudeMd = configurationService.getValue(PromptsConfig.USE_CLAUDE_MD) ?? false; const useCopilotInstructions = configurationService.getValue(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES) ?? false; - let copilotInstructionsFiles: URI[] = []; - if (useCopilotInstructions) { - copilotInstructionsFiles = await promptsService.listCopilotInstructionsMDs(token); - } + + const allFiles = await promptsService.listAgentInstructions(token); return { - agentsMd: { enabled: useAgentMd, files: agentMdFiles }, - copilotInstructions: { enabled: useCopilotInstructions, files: copilotInstructionsFiles } + agentsMd: { + enabled: useAgentMd, + files: allFiles.filter(f => f.type === AgentFileType.agentsMd).map(f => f.uri) + }, + claudeMd: { + enabled: useClaudeMd, + files: allFiles.filter(f => f.type === AgentFileType.claudeMd).map(f => f.uri) + }, + copilotInstructions: { + enabled: useCopilotInstructions, + files: allFiles.filter(f => f.type === AgentFileType.copilotInstructionsMd).map(f => f.uri) + } }; } @@ -407,7 +475,7 @@ function convertDiscoveryResultToFileStatus(result: IPromptFileDiscoveryResult): */ export function formatStatusOutput( statusInfos: ITypeStatusInfo[], - specialFiles: { agentsMd: { enabled: boolean; files: URI[] }; copilotInstructions: { enabled: boolean; files: URI[] } }, + specialFiles: ISpecialFilesStatus, workspaceFolders: readonly IWorkspaceFolder[] ): string { const lines: string[] = []; @@ -442,13 +510,29 @@ export function formatStatusOutput( if (specialFiles.copilotInstructions.enabled) { loadedCount += specialFiles.copilotInstructions.files.length; } + if (specialFiles.claudeMd.enabled) { + loadedCount += specialFiles.claudeMd.files.length; + } } lines.push(`**${typeName}**${enabledStatus}
`); - // Show stats line - use "skills" for skills type, "files" for others + // Show stats line - use "skills" for skills type, "hooks" for hooks type, "files" for others const statsParts: string[] = []; - if (loadedCount > 0) { + if (info.type === PromptsType.hook) { + // For hooks, show both file count and individual hook count + if (loadedCount > 0) { + statsParts.push(loadedCount === 1 + ? nls.localize('status.fileLoaded', '1 file loaded') + : nls.localize('status.filesLoaded', '{0} files loaded', loadedCount)); + } + if (info.parsedHooks && info.parsedHooks.length > 0) { + const hookCount = info.parsedHooks.length; + statsParts.push(hookCount === 1 + ? nls.localize('status.hookLoaded', '1 hook loaded') + : nls.localize('status.hooksLoaded', '{0} hooks loaded', hookCount)); + } + } else if (loadedCount > 0) { if (info.type === PromptsType.skill) { statsParts.push(loadedCount === 1 ? nls.localize('status.skillLoaded', '1 skill loaded') @@ -493,47 +577,51 @@ export function formatStatusOutput( } // Render each path with its files as a tree + // Skip for hooks since we show files with their hooks below let hasContent = false; - for (const path of allPaths) { - const pathFiles = filesByPath.get(path.uri.toString()) || []; - - if (path.exists) { - lines.push(`${path.displayPath}
`); - } else if (path.isDefault) { - // Default folders that don't exist - no error icon - lines.push(`${path.displayPath}
`); - } else { - // Custom folders that don't exist - show error - lines.push(`${ICON_ERROR} ${path.displayPath} - *${nls.localize('status.folderNotFound', 'Folder does not exist')}*
`); - } + if (info.type !== PromptsType.hook) { + for (const path of allPaths) { + const pathFiles = filesByPath.get(path.uri.toString()) || []; + + if (path.exists) { + lines.push(`${path.displayPath}
`); + } else if (path.isDefault) { + // Default folders that don't exist - no error icon + lines.push(`${path.displayPath}
`); + } else { + // Custom folders that don't exist - show error + lines.push(`${ICON_ERROR} ${path.displayPath} - *${nls.localize('status.folderNotFound', 'Folder does not exist')}*
`); + } - if (path.exists && pathFiles.length > 0) { - for (let i = 0; i < pathFiles.length; i++) { - const file = pathFiles[i]; - // Show the file ID: skill name for skills, basename for others - let fileName: string; - if (info.type === PromptsType.skill) { - fileName = file.name || `${basename(dirname(file.uri))}`; - } else { - fileName = basename(file.uri); - } - const isLast = i === pathFiles.length - 1; - const prefix = isLast ? TREE_END : TREE_BRANCH; - const filePath = getRelativePath(file.uri, workspaceFolders); - if (file.status === 'loaded') { - lines.push(`${prefix} [\`${fileName}\`](${filePath})
`); - } else if (file.status === 'overwritten') { - lines.push(`${prefix} ${ICON_WARN} [\`${fileName}\`](${filePath}) - *${nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file')}*
`); - } else { - lines.push(`${prefix} ${ICON_ERROR} [\`${fileName}\`](${filePath}) - *${file.reason}*
`); + if (path.exists && pathFiles.length > 0) { + for (let i = 0; i < pathFiles.length; i++) { + const file = pathFiles[i]; + // Show the file ID: skill name for skills, basename for others + let fileName: string; + if (info.type === PromptsType.skill) { + fileName = file.name || `${basename(dirname(file.uri))}`; + } else { + fileName = basename(file.uri); + } + const isLast = i === pathFiles.length - 1; + const prefix = isLast ? TREE_END : TREE_BRANCH; + const filePath = getRelativePath(file.uri, workspaceFolders); + if (file.status === 'loaded') { + lines.push(`${prefix} [\`${fileName}\`](${filePath})
`); + } else if (file.status === 'overwritten') { + lines.push(`${prefix} ${ICON_WARN} [\`${fileName}\`](${filePath}) - *${nls.localize('status.overwrittenByHigherPriority', 'Overwritten by higher priority file')}*
`); + } else { + lines.push(`${prefix} ${ICON_ERROR} [\`${fileName}\`](${filePath}) - *${file.reason}*
`); + } } } + hasContent = true; } - hasContent = true; } // Render unmatched files (e.g., from extensions) - group by extension ID - if (unmatchedFiles.length > 0) { + // Skip for hooks since we show files with their hooks below + if (info.type !== PromptsType.hook && unmatchedFiles.length > 0) { // Group files by extension ID const filesByExtension = new Map(); for (const file of unmatchedFiles) { @@ -608,6 +696,39 @@ export function formatStatusOutput( } } + // Special handling for hooks - display grouped by file, then by lifecycle + if (info.type === PromptsType.hook && info.parsedHooks && info.parsedHooks.length > 0) { + // Group hooks first by file, then by lifecycle within each file + const hooksByFile = new Map(); + for (const hook of info.parsedHooks) { + const fileKey = hook.fileUri.toString(); + const existing = hooksByFile.get(fileKey) ?? []; + existing.push(hook); + hooksByFile.set(fileKey, existing); + } + + // Display hooks grouped by file + const fileUris = Array.from(hooksByFile.keys()); + for (let fileIdx = 0; fileIdx < fileUris.length; fileIdx++) { + const fileKey = fileUris[fileIdx]; + const fileHooks = hooksByFile.get(fileKey)!; + const firstHook = fileHooks[0]; + const filePath = getRelativePath(firstHook.fileUri, workspaceFolders); + + // File as clickable link + lines.push(`[${firstHook.filePath}](${filePath})
`); + + // Flatten hooks with their lifecycle label + for (let i = 0; i < fileHooks.length; i++) { + const hook = fileHooks[i]; + const isLast = i === fileHooks.length - 1; + const prefix = isLast ? TREE_END : TREE_BRANCH; + lines.push(`${prefix} ${hook.hookTypeLabel}: \`${hook.commandLabel}\`
`); + } + } + hasContent = true; + } + if (!hasContent && info.enabled) { lines.push(`*${nls.localize('status.noFilesLoaded', 'No files loaded')}*`); } @@ -639,6 +760,8 @@ function getTypeName(type: PromptsType): string { return nls.localize('status.type.prompts', 'Prompt Files'); case PromptsType.skill: return nls.localize('status.type.skills', 'Skills'); + case PromptsType.hook: + return nls.localize('status.type.hooks', 'Hooks'); default: return type; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index e94ca5abf4eb8..89d29c70882fb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -7,13 +7,12 @@ import { localize } from '../../../../../nls.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; import { IChatSessionTiming } from '../../common/chatService/chatService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; export enum AgentSessionProviders { - Local = localChatSessionType, + Local = 'local', Background = 'copilotcli', Cloud = 'copilot-cloud-agent', Claude = 'claude-code', diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index b3d6bca7d5029..5ea09bdf87583 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -836,6 +836,15 @@ configurationRegistry.registerConfiguration({ disallowConfigurationDefault: true, tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'] }, + [PromptsConfig.USE_CLAUDE_MD]: { + type: 'boolean', + title: nls.localize('chat.useClaudeMd.title', "Use CLAUDE.md file",), + markdownDescription: nls.localize('chat.useClaudeMd.description', "Controls whether instructions from `CLAUDE.md` file found in workspace roots, .claude and ~/.claude folder are attached to all chat requests.",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'reusable prompts', 'prompt snippets', 'instructions'] + }, [PromptsConfig.USE_AGENT_SKILLS]: { type: 'boolean', title: nls.localize('chat.useAgentSkills.title', "Use Agent skills",), diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index b96a8a4b80762..3ba2c1aeb53a0 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -366,6 +366,16 @@ export interface IChatWidget { */ focusResponseItem(lastFocused?: boolean): void; focusInput(): void; + /** + * Focuses the Todos view in the chat widget. + * @returns Whether the operation succeeded (i.e., the Todos view was focused). + */ + focusTodosView(): boolean; + /** + * Toggles focus between the Todos view and the previous focus target in the chat widget. + * @returns Whether the operation succeeded (i.e., the focus was toggled). + */ + toggleTodosViewFocus(): boolean; hasInputFocus(): boolean; getModeRequestOptions(): Partial; getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 27b1e31708db7..388edd3f00c23 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { alert } from '../../../../../base/browser/ui/aria/aria.js'; import { basename } from '../../../../../base/common/resources.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { isCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; @@ -419,13 +420,13 @@ export class ViewAllSessionChangesAction extends Action2 { } registerAction2(ViewAllSessionChangesAction); -async function restoreSnapshotWithConfirmation(accessor: ServicesAccessor, item: ChatTreeItem): Promise { +async function restoreSnapshotWithConfirmationByRequestId(accessor: ServicesAccessor, sessionResource: URI, requestId: string): Promise { const configurationService = accessor.get(IConfigurationService); const dialogService = accessor.get(IDialogService); const chatWidgetService = accessor.get(IChatWidgetService); - const widget = chatWidgetService.getWidgetBySessionResource(item.sessionResource); + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); const chatService = accessor.get(IChatService); - const chatModel = chatService.getSession(item.sessionResource); + const chatModel = chatService.getSession(sessionResource); if (!chatModel) { return; } @@ -435,59 +436,69 @@ async function restoreSnapshotWithConfirmation(accessor: ServicesAccessor, item: return; } - const requestId = isRequestVM(item) ? item.id : - isResponseVM(item) ? item.requestId : undefined; + const chatRequests = chatModel.getRequests(); + const itemIndex = chatRequests.findIndex(request => request.id === requestId); + if (itemIndex === -1) { + return; + } - if (requestId) { - const chatRequests = chatModel.getRequests(); - const itemIndex = chatRequests.findIndex(request => request.id === requestId); - const editsToUndo = chatRequests.length - itemIndex; - - const requestsToRemove = chatRequests.slice(itemIndex); - const requestIdsToRemove = new Set(requestsToRemove.map(request => request.id)); - const entriesModifiedInRequestsToRemove = session.entries.get().filter((entry) => requestIdsToRemove.has(entry.lastModifyingRequestId)) ?? []; - const shouldPrompt = entriesModifiedInRequestsToRemove.length > 0 && configurationService.getValue('chat.editing.confirmEditRequestRemoval') === true; - - let message: string; - if (editsToUndo === 1) { - if (entriesModifiedInRequestsToRemove.length === 1) { - message = localize('chat.removeLast.confirmation.message2', "This will remove your last request and undo the edits made to {0}. Do you want to proceed?", basename(entriesModifiedInRequestsToRemove[0].modifiedURI)); - } else { - message = localize('chat.removeLast.confirmation.multipleEdits.message', "This will remove your last request and undo edits made to {0} files in your working set. Do you want to proceed?", entriesModifiedInRequestsToRemove.length); - } + const editsToUndo = chatRequests.length - itemIndex; + + const requestsToRemove = chatRequests.slice(itemIndex); + const requestIdsToRemove = new Set(requestsToRemove.map(request => request.id)); + const entriesModifiedInRequestsToRemove = session.entries.get().filter((entry) => requestIdsToRemove.has(entry.lastModifyingRequestId)) ?? []; + const shouldPrompt = entriesModifiedInRequestsToRemove.length > 0 && configurationService.getValue('chat.editing.confirmEditRequestRemoval') === true; + + let message: string; + if (editsToUndo === 1) { + if (entriesModifiedInRequestsToRemove.length === 1) { + message = localize('chat.removeLast.confirmation.message2', "This will remove your last request and undo the edits made to {0}. Do you want to proceed?", basename(entriesModifiedInRequestsToRemove[0].modifiedURI)); } else { - if (entriesModifiedInRequestsToRemove.length === 1) { - message = localize('chat.remove.confirmation.message2', "This will remove all subsequent requests and undo edits made to {0}. Do you want to proceed?", basename(entriesModifiedInRequestsToRemove[0].modifiedURI)); - } else { - message = localize('chat.remove.confirmation.multipleEdits.message', "This will remove all subsequent requests and undo edits made to {0} files in your working set. Do you want to proceed?", entriesModifiedInRequestsToRemove.length); - } + message = localize('chat.removeLast.confirmation.multipleEdits.message', "This will remove your last request and undo edits made to {0} files in your working set. Do you want to proceed?", entriesModifiedInRequestsToRemove.length); } + } else { + if (entriesModifiedInRequestsToRemove.length === 1) { + message = localize('chat.remove.confirmation.message2', "This will remove all subsequent requests and undo edits made to {0}. Do you want to proceed?", basename(entriesModifiedInRequestsToRemove[0].modifiedURI)); + } else { + message = localize('chat.remove.confirmation.multipleEdits.message', "This will remove all subsequent requests and undo edits made to {0} files in your working set. Do you want to proceed?", entriesModifiedInRequestsToRemove.length); + } + } - const confirmation = shouldPrompt - ? await dialogService.confirm({ - title: editsToUndo === 1 - ? localize('chat.removeLast.confirmation.title', "Do you want to undo your last edit?") - : localize('chat.remove.confirmation.title', "Do you want to undo {0} edits?", editsToUndo), - message: message, - primaryButton: localize('chat.remove.confirmation.primaryButton', "Yes"), - checkbox: { label: localize('chat.remove.confirmation.checkbox', "Don't ask again"), checked: false }, - type: 'info' - }) - : { confirmed: true }; + const confirmation = shouldPrompt + ? await dialogService.confirm({ + title: editsToUndo === 1 + ? localize('chat.removeLast.confirmation.title', "Do you want to undo your last edit?") + : localize('chat.remove.confirmation.title', "Do you want to undo {0} edits?", editsToUndo), + message: message, + primaryButton: localize('chat.remove.confirmation.primaryButton', "Yes"), + checkbox: { label: localize('chat.remove.confirmation.checkbox', "Don't ask again"), checked: false }, + type: 'info' + }) + : { confirmed: true }; - if (!confirmation.confirmed) { - widget?.viewModel?.model.setCheckpoint(undefined); - return; - } + if (!confirmation.confirmed) { + widget?.viewModel?.model.setCheckpoint(undefined); + return; + } - if (confirmation.checkboxChecked) { - await configurationService.updateValue('chat.editing.confirmEditRequestRemoval', false); - } + if (confirmation.checkboxChecked) { + await configurationService.updateValue('chat.editing.confirmEditRequestRemoval', false); + } - // Restore the snapshot to what it was before the request(s) that we deleted - const snapshotRequestId = chatRequests[itemIndex].id; - await session.restoreSnapshot(snapshotRequestId, undefined); + // Restore the snapshot to what it was before the request(s) that we deleted + const snapshotRequestId = chatRequests[itemIndex].id; + await session.restoreSnapshot(snapshotRequestId, undefined); +} + +async function restoreSnapshotWithConfirmation(accessor: ServicesAccessor, item: ChatTreeItem): Promise { + const requestId = isRequestVM(item) ? item.id : + isResponseVM(item) ? item.requestId : undefined; + + if (!requestId) { + return; } + + await restoreSnapshotWithConfirmationByRequestId(accessor, item.sessionResource, requestId); } registerAction2(class RemoveAction extends Action2 { @@ -593,9 +604,14 @@ registerAction2(class RestoreLastCheckpoint extends Action2 { super({ id: 'workbench.action.chat.restoreLastCheckpoint', title: localize2('chat.restoreLastCheckpoint.label', "Restore to Last Checkpoint"), - f1: false, + f1: true, category: CHAT_CATEGORY, icon: Codicon.discard, + precondition: ContextKeyExpr.and( + ChatContextKeys.inChatSession, + ContextKeyExpr.equals(`config.${ChatConfiguration.CheckpointsEnabled}`, true), + ChatContextKeys.lockedToCodingAgent.negate() + ), menu: [ { id: MenuId.ChatMessageFooter, @@ -616,30 +632,27 @@ registerAction2(class RestoreLastCheckpoint extends Action2 { item = widget?.getFocus(); } - if (!item) { + const sessionResource = widget?.viewModel?.sessionResource ?? (isChatTreeItem(item) ? item.sessionResource : undefined); + if (!sessionResource) { return; } - const chatModel = chatService.getSession(item.sessionResource); - if (!chatModel) { + const chatModel = chatService.getSession(sessionResource); + if (!chatModel?.editingSession) { return; } - const session = chatModel.editingSession; - if (!session) { + const checkpointRequest = chatModel.checkpoint; + if (!checkpointRequest) { + alert(localize('chat.restoreCheckpoint.none', 'There is no checkpoint to restore.')); return; } - await restoreSnapshotWithConfirmation(accessor, item); + widget?.viewModel?.model.setCheckpoint(checkpointRequest.id); + widget?.focusInput(); + widget?.input.setValue(checkpointRequest.message.text, false); - if (isResponseVM(item)) { - widget?.viewModel?.model.setCheckpoint(item.requestId); - const request = chatModel.getRequests().find(request => request.id === item.requestId); - if (request) { - widget?.focusInput(); - widget?.input.setValue(request.message.text, false); - } - } + await restoreSnapshotWithConfirmationByRequestId(accessor, sessionResource, checkpointRequest.id); } }); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index 214b76c56c0c5..3604a84f02699 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -11,20 +11,17 @@ import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { formatHookCommandLabel, HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; +import { HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; import { NEW_HOOK_COMMAND_ID } from './newPromptFileActions.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; -import { findHookCommandSelection } from './hookUtils.js'; -import { getHookSourceFormatLabel, HookSourceFormat, isReadOnlyHookSource, parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; @@ -33,24 +30,8 @@ import { IPathService } from '../../../../services/path/common/pathService.js'; */ const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks'; -interface IHookEntry { - readonly hookType: HookType; - readonly hookTypeLabel: string; - /** The original hook type ID as it appears in the JSON file (for selection lookup) */ - readonly originalHookTypeId: string; - readonly fileUri: URI; - readonly filePath: string; - readonly displayLabel: string; - readonly commandFieldName: 'command' | 'bash' | 'powershell' | undefined; - readonly index: number; - /** The source format (Copilot, Claude) */ - readonly sourceFormat: HookSourceFormat; - /** Whether this hook is from a read-only source (Claude settings) */ - readonly isReadOnly: boolean; -} - interface IHookQuickPickItem extends IQuickPickItem { - readonly hookEntry?: IHookEntry; + readonly hookEntry?: IParsedHook; readonly commandId?: string; } @@ -90,49 +71,14 @@ class ManageHooksAction extends Action2 { const workspaceRootUri = workspaceFolder?.uri; const userHomeUri = await pathService.userHome(); const userHome = userHomeUri.fsPath ?? userHomeUri.path; - - // Get all hook files - const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); - - // Parse hook files to extract hook entries using format-aware parsing - const hookEntries: IHookEntry[] = []; - - for (const hookFile of hookFiles) { - try { - const content = await fileService.readFile(hookFile.uri); - const json = JSON.parse(content.value.toString()); - - // Use format-aware parsing - const { format, hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); - const isReadOnly = isReadOnlyHookSource(format); - - for (const [hookType, { hooks: commands, originalId }] of hooks) { - const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); - if (!hookTypeMeta) { - continue; - } - - for (let i = 0; i < commands.length; i++) { - const hookCommand = commands[i]; - const displayLabel = formatHookCommandLabel(hookCommand) || localize('commands.hook.emptyCommand', '(empty command)'); - hookEntries.push({ - hookType, - hookTypeLabel: hookTypeMeta.label, - originalHookTypeId: originalId, - fileUri: hookFile.uri, - filePath: labelService.getUriLabel(hookFile.uri, { relative: true }), - displayLabel, - commandFieldName: hookCommand.command !== undefined ? 'command' : hookCommand.bash !== undefined ? 'bash' : 'powershell', - index: i, - sourceFormat: format, - isReadOnly - }); - } - } - } catch { - // Skip files that can't be parsed - } - } + const hookEntries = await parseAllHookFiles( + promptsService, + fileService, + labelService, + workspaceRootUri, + userHome, + CancellationToken.None + ); // Build quick pick items grouped by hook type const items: (IHookQuickPickItem | IQuickPickSeparator)[] = []; @@ -145,7 +91,7 @@ class ManageHooksAction extends Action2 { }); // Group entries by hook type - const groupedByType = new Map(); + const groupedByType = new Map(); for (const entry of hookEntries) { const existing = groupedByType.get(entry.hookType) ?? []; existing.push(entry); @@ -170,14 +116,11 @@ class ManageHooksAction extends Action2 { }); for (const entry of entries) { - // Build description with source format indicator for read-only hooks - let description = entry.filePath; - if (entry.isReadOnly) { - description = `$(lock) ${getHookSourceFormatLabel(entry.sourceFormat)} · ${description}`; - } + // Use relative path from labelService for consistent display + const description = labelService.getUriLabel(entry.fileUri, { relative: true }); items.push({ - label: entry.displayLabel, + label: entry.commandLabel, description, hookEntry: entry }); @@ -204,15 +147,21 @@ class ManageHooksAction extends Action2 { const entry = selected.hookEntry; let selection: ITextEditorSelection | undefined; + // Determine the command field name to highlight + const commandFieldName = entry.command.command !== undefined ? 'command' + : entry.command.bash !== undefined ? 'bash' + : entry.command.powershell !== undefined ? 'powershell' + : undefined; + // Try to find the command field to highlight - if (entry.commandFieldName) { + if (commandFieldName) { try { const content = await fileService.readFile(entry.fileUri); selection = findHookCommandSelection( content.value.toString(), entry.originalHookTypeId, entry.index, - entry.commandFieldName + commandFieldName ); } catch { // Ignore errors and just open without selection diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts index 6f54d82315646..885054127b83b 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -5,6 +5,15 @@ import { findNodeAtLocation, Node, parseTree } from '../../../../../base/common/json.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { formatHookCommandLabel, HOOK_TYPES, HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; +import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import * as nls from '../../../../../nls.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; /** * Converts an offset in content to a 1-based line and column. @@ -102,3 +111,71 @@ export function findHookCommandSelection(content: string, hookType: string, inde endColumn: end.column }; } + +/** + * Parsed hook information. + */ +export interface IParsedHook { + hookType: HookType; + hookTypeLabel: string; + command: IHookCommand; + commandLabel: string; + fileUri: URI; + filePath: string; + index: number; + /** The original hook type ID as it appears in the JSON file */ + originalHookTypeId: string; +} + +/** + * Parses all hook files and extracts individual hooks. + * This is a shared helper used by both the configure action and diagnostics. + */ +export async function parseAllHookFiles( + promptsService: IPromptsService, + fileService: IFileService, + labelService: ILabelService, + workspaceRootUri: URI | undefined, + userHome: string, + token: CancellationToken +): Promise { + const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, token); + const parsedHooks: IParsedHook[] = []; + + for (const hookFile of hookFiles) { + try { + const content = await fileService.readFile(hookFile.uri); + const json = JSON.parse(content.value.toString()); + + // Use format-aware parsing + const { hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + + for (const [hookType, { hooks: commands, originalId }] of hooks) { + const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + if (!hookTypeMeta) { + continue; + } + + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const commandLabel = formatHookCommandLabel(command) || nls.localize('commands.hook.emptyCommand', '(empty command)'); + parsedHooks.push({ + hookType, + hookTypeLabel: hookTypeMeta.label, + command, + commandLabel, + fileUri: hookFile.uri, + filePath: labelService.getUriLabel(hookFile.uri, { relative: true }), + index: i, + originalHookTypeId: originalId + }); + } + } + } catch (error) { + // Skip files that can't be parsed, but surface the failure for diagnostics + console.error('Failed to read or parse hook file', hookFile.uri.toString(), error); + } + } + + return parsedHooks; +} diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index 9bc9508463c8d..d79e8dad50eab 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -7,7 +7,7 @@ import { localize } from '../../../../../../nls.js'; import { URI } from '../../../../../../base/common/uri.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { IExtensionPromptPath, IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { AgentFileType, IExtensionPromptPath, IPromptPath, IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { dirname, extUri, joinPath } from '../../../../../../base/common/resources.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; @@ -23,8 +23,6 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { askForPromptSourceFolder } from './askForPromptSourceFolder.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { PromptFileRewriter } from '../promptFileRewriter.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; @@ -273,7 +271,6 @@ export class PromptFilePickers { @IInstantiationService private readonly _instaService: IInstantiationService, @IPromptsService private readonly _promptsService: IPromptsService, @ILabelService private readonly _labelService: ILabelService, - @IConfigurationService private readonly _configurationService: IConfigurationService, @IProductService private readonly _productService: IProductService, ) { } @@ -401,18 +398,13 @@ export class PromptFilePickers { // listPromptFilesForStorage() because that function only handles *.instructions.md files (under `.github/instructions/`, etc.) let agentInstructionFiles: IPromptPath[] = []; if (options.type === PromptsType.instructions) { - const useNestedAgentMD = this._configurationService.getValue(PromptsConfig.USE_NESTED_AGENT_MD); - const agentInstructionUris = [ - ...await this._promptsService.listCopilotInstructionsMDs(token), - ...await this._promptsService.listAgentMDs(token, !!useNestedAgentMD) - ]; - agentInstructionFiles = agentInstructionUris.map(uri => { - const folderName = this._labelService.getUriLabel(dirname(uri), { relative: true }); + const agentInstructionUris = await this._promptsService.listAgentInstructions(token); + agentInstructionFiles = agentInstructionUris.map(agentInstructionFile => { + const folderName = this._labelService.getUriLabel(dirname(agentInstructionFile.uri), { relative: true }); // Don't show the folder path for files under .github folder (namely, copilot-instructions.md) since that is only defined once per repo. - const shouldShowFolderPath = folderName?.toLowerCase() !== '.github'; return { - uri, - description: shouldShowFolderPath ? folderName : undefined, + uri: agentInstructionFile.uri, + description: agentInstructionFile.type !== AgentFileType.copilotInstructionsMd ? folderName : undefined, storage: PromptsStorage.local, type: options.type } satisfies IPromptPath; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index c091f0f7d1d97..c221d7a54bd07 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -34,12 +34,14 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IHooksExecutionService, IPreToolUseCallerInput } from '../../common/hooksExecutionService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../common/chatModes.js'; import { ConfirmedReason, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; +import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, isToolSet, ToolDataSource, toolMatchesModel, ToolSet, VSCodeToolReference, IToolSet, ToolSetForModel, IToolInvokedEvent } from '../../common/tools/languageModelToolsService.js'; @@ -117,6 +119,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IStorageService private readonly _storageService: IStorageService, @ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService, + @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, ) { super(); @@ -356,9 +359,118 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } + /** + * Execute the preToolUse hook and handle denial. + * Returns a tool result if the hook denied execution, or undefined to continue. + * @param pendingInvocation If there's an existing streaming invocation from beginToolCall, pass it here to cancel it instead of creating a new one. + */ + private async _executePreToolUseHookAndHandleDenial( + dto: IToolInvocation, + toolData: IToolData | undefined, + request: IChatRequestModel | undefined, + pendingInvocation: ChatToolInvocation | undefined, + token: CancellationToken + ): Promise { + // Skip hook if no session context or tool doesn't exist + if (!dto.context?.sessionResource || !toolData) { + return undefined; + } + + const hookInput: IPreToolUseCallerInput = { + toolName: dto.toolId, + toolArgs: dto.parameters, + }; + const hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token); + + if (hookResult?.permissionDecision === 'deny') { + const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); + const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", 'preToolUse', hookReason); + this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); + + // Handle the tool invocation in cancelled state + if (toolData) { + if (pendingInvocation) { + // If there's an existing streaming invocation, cancel it + pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); + } else if (request) { + // Otherwise create a new cancelled invocation and add it to the chat model + const toolInvocation = ChatToolInvocation.createCancelled( + { toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId }, + dto.parameters, + ToolConfirmKind.Denied, + reason + ); + this._chatService.appendProgress(request, toolInvocation); + } + } + + const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason); + return { + content: [{ kind: 'text', value: denialMessage }], + toolResultError: hookReason, + }; + } + + return undefined; + } + async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); + const toolData = this._tools.get(dto.toolId)?.data; + let model: IChatModel | undefined; + let request: IChatRequestModel | undefined; + if (dto.context?.sessionResource) { + model = this._chatService.getSession(dto.context.sessionResource); + request = model?.getRequests().at(-1); + } + + // Check if there's an existing pending tool call from streaming phase BEFORE hook check + let pendingToolCallKey: string | undefined; + let toolInvocation: ChatToolInvocation | undefined; + if (this._pendingToolCalls.has(dto.callId)) { + pendingToolCallKey = dto.callId; + toolInvocation = this._pendingToolCalls.get(dto.callId); + } else if (dto.chatStreamToolCallId && this._pendingToolCalls.has(dto.chatStreamToolCallId)) { + pendingToolCallKey = dto.chatStreamToolCallId; + toolInvocation = this._pendingToolCalls.get(dto.chatStreamToolCallId); + } + + let requestId: string | undefined; + let store: DisposableStore | undefined; + if (dto.context && request) { + requestId = request.id; + store = new DisposableStore(); + if (!this._callsByRequestId.has(requestId)) { + this._callsByRequestId.set(requestId, []); + } + const trackedCall: ITrackedCall = { store }; + this._callsByRequestId.get(requestId)!.push(trackedCall); + + const source = new CancellationTokenSource(); + store.add(toDisposable(() => { + source.dispose(true); + })); + store.add(token.onCancellationRequested((() => { + IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied }); + source.cancel(); + }))); + store.add(source.token.onCancellationRequested(() => { + IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied }); + })); + token = source.token; + } + + // Execute preToolUse hook - returns early if hook denies execution + const hookDenialResult = await this._executePreToolUseHookAndHandleDenial(dto, toolData, request, toolInvocation, token); + if (hookDenialResult) { + // Clean up pending tool call if it exists + if (pendingToolCallKey) { + this._pendingToolCalls.delete(pendingToolCallKey); + } + return hookDenialResult; + } + // Fire the event to notify listeners that a tool is being invoked this._onDidInvokeTool.fire({ toolId: dto.toolId, @@ -383,62 +495,29 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - // Check if there's an existing pending tool call from streaming phase - // Try both the callId and the chatStreamToolCallId (if provided) as lookup keys - let pendingToolCallKey: string | undefined; - let toolInvocation: ChatToolInvocation | undefined; - if (this._pendingToolCalls.has(dto.callId)) { - pendingToolCallKey = dto.callId; - toolInvocation = this._pendingToolCalls.get(dto.callId); - } else if (dto.chatStreamToolCallId && this._pendingToolCalls.has(dto.chatStreamToolCallId)) { - pendingToolCallKey = dto.chatStreamToolCallId; - toolInvocation = this._pendingToolCalls.get(dto.chatStreamToolCallId); - } + // Note: pending invocation lookup was already done above for the hook check const hadPendingInvocation = !!toolInvocation; if (hadPendingInvocation && pendingToolCallKey) { // Remove from pending since we're now invoking it this._pendingToolCalls.delete(pendingToolCallKey); } - let requestId: string | undefined; - let store: DisposableStore | undefined; let toolResult: IToolResult | undefined; let prepareTimeWatch: StopWatch | undefined; let invocationTimeWatch: StopWatch | undefined; let preparedInvocation: IPreparedToolInvocation | undefined; try { if (dto.context) { - store = new DisposableStore(); - const model = this._chatService.getSession(dto.context.sessionResource); if (!model) { throw new Error(`Tool called for unknown chat session`); } - const request = model.getRequests().at(-1)!; - requestId = request.id; + if (!request) { + throw new Error(`Tool called for unknown chat request`); + } dto.modelId = request.modelId; dto.userSelectedTools = request.userSelectedTools && { ...request.userSelectedTools }; - // Replace the token with a new token that we can cancel when cancelToolCallsForRequest is called - if (!this._callsByRequestId.has(requestId)) { - this._callsByRequestId.set(requestId, []); - } - const trackedCall: ITrackedCall = { store }; - this._callsByRequestId.get(requestId)!.push(trackedCall); - - const source = new CancellationTokenSource(); - store.add(toDisposable(() => { - source.dispose(true); - })); - store.add(token.onCancellationRequested(() => { - IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied }); - source.cancel(); - })); - store.add(source.token.onCancellationRequested(() => { - IChatToolInvocation.confirmWith(toolInvocation, { type: ToolConfirmKind.Denied }); - })); - token = source.token; - prepareTimeWatch = StopWatch.create(true); preparedInvocation = await this.prepareToolInvocation(tool, dto, token); prepareTimeWatch.stop(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts index fc89ccc8aa34b..fe4903eff3b79 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts @@ -12,7 +12,7 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { ChatErrorLevel, IChatResponseErrorDetailsConfirmationButton, IChatSendRequestOptions, IChatService } from '../../../common/chatService/chatService.js'; import { assertIsResponseVM, IChatErrorDetailsPart, IChatRendererContent } from '../../../common/model/chatViewModel.js'; -import { IChatWidgetService } from '../../chat.js'; +import { IChatAccessibilityService, IChatWidgetService } from '../../chat.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatErrorWidget } from './chatErrorContentPart.js'; @@ -31,6 +31,7 @@ export class ChatErrorConfirmationContentPart extends Disposable implements ICha @IInstantiationService instantiationService: IInstantiationService, @IChatWidgetService chatWidgetService: IChatWidgetService, @IChatService chatService: IChatService, + @IChatAccessibilityService private readonly chatAccessibilityService: IChatAccessibilityService, ) { super(); @@ -58,6 +59,7 @@ export class ChatErrorConfirmationContentPart extends Disposable implements ICha const widget = chatWidgetService.getWidgetBySessionResource(element.sessionResource); options.userSelectedModelId = widget?.input.currentLanguageModel; Object.assign(options, widget?.getModeRequestOptions()); + this.chatAccessibilityService.acceptRequest(element.sessionResource); await chatService.sendRequest(element.sessionResource, prompt, options); })); }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts index d21b5f1af405c..619d00a2f5878 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; +import { trackFocus } from '../../../../../../base/browser/dom.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { IconLabel } from '../../../../../../base/browser/ui/iconLabel/iconLabel.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; @@ -12,7 +13,7 @@ import { Disposable, DisposableStore } from '../../../../../../base/common/lifec import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; -import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService, IContextKey } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchList } from '../../../../../../platform/list/browser/listService.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; @@ -123,6 +124,8 @@ export class ChatTodoListWidget extends Disposable { private _currentSessionResource: URI | undefined; private _todoList: WorkbenchList | undefined; + private readonly _inChatTodoListContextKey: IContextKey; + constructor( @IChatTodoListService private readonly chatTodoListService: IChatTodoListService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -130,8 +133,14 @@ export class ChatTodoListWidget extends Disposable { ) { super(); + this._inChatTodoListContextKey = ChatContextKeys.inChatTodoList.bindTo(contextKeyService); this.domNode = this.createChatTodoWidget(); + // Track focus state for context key + const focusTracker = this._register(trackFocus(this.domNode)); + this._register(focusTracker.onDidFocus(() => this._inChatTodoListContextKey.set(true))); + this._register(focusTracker.onDidBlur(() => this._inChatTodoListContextKey.set(false))); + // Listen to context key changes to update clear button state when request state changes this._register(this.contextKeyService.onDidChangeContext(e => { if (e.affectsSome(new Set([ChatContextKeys.requestInProgress.key]))) { @@ -237,6 +246,27 @@ export class ChatTodoListWidget extends Disposable { } } + public hasTodos(): boolean { + return this.domNode.classList.contains('has-todos') && !!this._todoList && this._todoList.length > 0; + } + + public hasFocus(): boolean { + return dom.isAncestorOfActiveElement(this.todoListContainer); + } + + public focus(): boolean { + if (!this.hasTodos()) { + return false; + } + + if (!this._isExpanded) { + this.toggleExpanded(); + } + + this._todoList?.domFocus(); + return this.hasFocus(); + } + private updateTodoDisplay(): void { if (!this._currentSessionResource) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css index f9dd8f5cda149..0ab3f6bebb5e2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css @@ -187,6 +187,7 @@ div.chat-terminal-content-part.progress-step > div.chat-terminal-output-containe .chat-terminal-output-terminal .xterm-viewport { background: inherit !important; } + .chat-terminal-output-terminal.chat-terminal-output-terminal-no-output { display: none; } @@ -211,6 +212,15 @@ div.chat-terminal-content-part.progress-step > div.chat-terminal-output-containe display: none; } +/* Allow clicks to pass through scrollbar track to reach inline links, but keep slider draggable */ +.chat-terminal-output-container > .monaco-scrollable-element > .scrollbar.horizontal { + pointer-events: none; +} + +.chat-terminal-output-container > .monaco-scrollable-element > .scrollbar.horizontal .slider { + pointer-events: auto; +} + .chat-terminal-output div, .chat-terminal-output span { height: auto; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 7e8eb8b14773f..c60d8cb52cf9f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -183,6 +183,10 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa return this.instantiationService.createInstance(ChatTerminalToolProgressPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer, this.editorPool, this.currentWidthDelegate, this.codeBlockStartIndex, this.codeBlockModelCollection); } + if (this.toolInvocation.toolSpecificData?.kind === 'resources' && this.toolInvocation.toolSpecificData.values.length > 0) { + return this.instantiationService.createInstance(ChatResultListSubPart, this.toolInvocation, this.context, this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage, this.toolInvocation.toolSpecificData.values, this.listPool); + } + const resultDetails = IChatToolInvocation.resultDetails(this.toolInvocation); if (Array.isArray(resultDetails) && resultDetails.length) { return this.instantiationService.createInstance(ChatResultListSubPart, this.toolInvocation, this.context, this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage, resultDetails, this.listPool); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index 9e35ac0465837..96c8fd5f7c780 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -53,11 +53,26 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { return part.domNode; } else { const container = document.createElement('div'); - const progressObservable = this.toolInvocation.kind === 'toolInvocation' ? this.toolInvocation.state.map((s, r) => s.type === IChatToolInvocation.StateKind.Executing ? s.progress.read(r) : undefined) : undefined; this._register(autorun(reader => { - const progress = progressObservable?.read(reader); + let progressContent: IMarkdownString | string | undefined; const key = this.getAnnouncementKey('progress'); - const progressContent = progress?.message ?? this.toolInvocation.invocationMessage; + + if (this.toolInvocation.kind === 'toolInvocation') { + const state = this.toolInvocation.state.read(reader); + + // Handle cancelled state with reason message + if (state.type === IChatToolInvocation.StateKind.Cancelled && state.reasonMessage) { + progressContent = state.reasonMessage; + } else if (state.type === IChatToolInvocation.StateKind.Executing) { + const progress = state.progress.read(reader); + progressContent = progress?.message ?? this.toolInvocation.invocationMessage; + } else { + progressContent = this.toolInvocation.invocationMessage; + } + } else { + progressContent = this.toolInvocation.invocationMessage; + } + // Don't render anything if there's no meaningful content if (!this.hasMeaningfulContent(progressContent)) { dom.clearNode(container); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 383d91fa9f059..d442181f2235e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -23,6 +23,7 @@ import { FuzzyScore } from '../../../../../base/common/filters.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Disposable, DisposableStore, IDisposable, dispose, thenIfNotDisposed, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ScrollEvent } from '../../../../../base/common/scrollable.js'; @@ -45,6 +46,9 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IMarkdownRenderer } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { FocusMode } from '../../../../../platform/native/common/native.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { IWorkbenchIssueService } from '../../../issue/common/issue.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; @@ -102,6 +106,7 @@ import { autorun, observableValue } from '../../../../../base/common/observable. import { RunSubagentTool } from '../../common/tools/builtinTools/runSubagentTool.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { IChatTipService } from '../chatTipService.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; const $ = dom.$; @@ -174,6 +179,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer>(); + private readonly _notifiedQuestionCarousels = new WeakSet(); + private readonly _questionCarouselToast = this._register(new DisposableStore()); + private readonly chatContentMarkdownRenderer: IMarkdownRenderer; private readonly markdownDecorationsRenderer: ChatMarkdownDecorationsRenderer; protected readonly _onDidClickFollowup = this._register(new Emitter()); @@ -241,6 +249,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0 && carousel.questions[0].message ? carousel.questions[0].message : localize('chat.questionCarouselNeedsInputSR', "Chat input required."); + const stringQuestion = typeof question === 'string' ? question : question.value; + const alertMessage = questionCount === 1 + ? localize('chat.questionCarouselAlertOne', "Chat input required (1 question): {0}", stringQuestion) + : localize('chat.questionCarouselAlertMany', "Chat input required ({0} questions): {1}", questionCount, stringQuestion); + this.accessibilityService.alert(alertMessage); + this._notifiedQuestionCarousels.add(carousel); + + // Reuse the existing confirmation notification setting. + if (!this.configService.getValue('chat.notifyWindowOnConfirmation')) { + return; + } + + if (!isResponseVM(context.element)) { + return; + } + + const widget = this.chatWidgetService.getWidgetBySessionResource(context.element.sessionResource); + if (!widget) { + return; + } + const signalMessage = questionCount === 1 + ? localize('chat.questionCarouselSignalOne', "Chat needs your input (1 question).") + : localize('chat.questionCarouselSignalMany', "Chat needs your input ({0} questions).", questionCount); + this.accessibilitySignalService.playSignal(AccessibilitySignal.chatUserActionRequired, { allowManyInParallel: true, customAlertMessage: signalMessage }); + + const targetWindow = dom.getWindow(widget.domNode); + if (!targetWindow || targetWindow.document.hasFocus()) { + return; + } + + + const sessionTitle = widget.viewModel?.model.title; + const notificationTitle = sessionTitle ? localize('chatTitle', "Chat: {0}", sessionTitle) : localize('chat.untitledChat', "Untitled Chat"); + + (async () => { + try { + await this.hostService.focus(targetWindow, { mode: FocusMode.Notify }); + + // Dispose any previous unhandled notifications to avoid replacement/coalescing. + this._questionCarouselToast.clear(); + + const cts = new CancellationTokenSource(); + this._questionCarouselToast.add(toDisposable(() => cts.dispose(true))); + + const { clicked, actionIndex } = await this.hostService.showToast({ + title: notificationTitle, + body: signalMessage, + actions: [localize('openChat', "Open Chat")], + }, cts.token); + + this._questionCarouselToast.clear(); + + if (clicked || actionIndex === 0) { + await this.hostService.focus(targetWindow, { mode: FocusMode.Force }); + await this.chatWidgetService.reveal(widget); + widget.focusInput(); + } + } catch (error) { + this.logService.trace('ChatListItemRenderer#_notifyOnQuestionCarousel', toErrorMessage(error)); + } + })(); + } + private removeCarouselFromTracking(context: IChatContentPartRenderContext, part: ChatQuestionCarouselPart): void { if (isResponseVM(context.element)) { const carousels = this.pendingQuestionCarousels.get(context.element.sessionResource); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 6920f80ebb2b8..f85397da22e73 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -698,6 +698,27 @@ export class ChatWidget extends Disposable implements IChatWidget { this._onDidFocus.fire(); } + focusTodosView(): boolean { + if (!this.input.hasVisibleTodos()) { + return false; + } + + return this.input.focusTodoList(); + } + + toggleTodosViewFocus(): boolean { + if (!this.input.hasVisibleTodos()) { + return false; + } + + if (this.input.isTodoListFocused()) { + this.focusInput(); + return true; + } + + return this.input.focusTodoList(); + } + hasInputFocus(): boolean { return this.input.hasFocus(); } @@ -892,17 +913,7 @@ export class ChatWidget extends Disposable implements IChatWidget { */ private async _checkForAgentInstructionFiles(): Promise { try { - const useCopilotInstructionsFiles = this.configurationService.getValue(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES); - const useAgentMd = this.configurationService.getValue(PromptsConfig.USE_AGENT_MD); - if (!useCopilotInstructionsFiles && !useAgentMd) { - // If both settings are disabled, return true to hide the hint (since the features aren't enabled) - return true; - } - return ( - (await this.promptsService.listCopilotInstructionsMDs(CancellationToken.None)).length > 0 || - // Note: only checking for AGENTS.md files at the root folder, not ones in subfolders. - (await this.promptsService.listAgentMDs(CancellationToken.None, false)).length > 0 - ); + return (await this.promptsService.listAgentInstructions(CancellationToken.None)).length > 0; } catch (error) { // On error, assume no instruction files exist to be safe this.logService.warn('[ChatWidget] Error checking for instruction files:', error); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 91ca4894116f9..137ade79af9fe 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1253,6 +1253,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._inputEditor.hasWidgetFocus(); } + focusTodoList(): boolean { + return this._chatInputTodoListWidget.value?.focus() ?? false; + } + + isTodoListFocused(): boolean { + return this._chatInputTodoListWidget.value?.hasFocus() ?? false; + } + + hasVisibleTodos(): boolean { + return this._chatInputTodoListWidget.value?.hasTodos() ?? false; + } + /** * Reset the input and update history. * @param userQuery If provided, this will be added to the history. Followups and programmatic queries should not be passed. diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 4331b69fa59ba..07dec080b330b 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -34,6 +34,7 @@ export namespace ChatContextKeys { export const inChatInput = new RawContextKey('inChatInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the chat input, false otherwise.") }); export const inChatSession = new RawContextKey('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); export const inChatEditor = new RawContextKey('inChatEditor', false, { type: 'boolean', description: localize('inChatEditor', "Whether focus is in a chat editor.") }); + export const inChatTodoList = new RawContextKey('inChatTodoList', false, { type: 'boolean', description: localize('inChatTodoList', "True when focus is in the chat todo list.") }); export const inChatTerminalToolOutput = new RawContextKey('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") }); export const chatModeKind = new RawContextKey('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") }); export const chatModeName = new RawContextKey('chatModeName', '', { type: 'string', description: localize('chatModeName', "The name of the current chat mode (e.g. 'Plan' for custom modes).") }); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index ff9fdf7e038b5..c89e92a9e385c 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -535,7 +535,7 @@ export type ConfirmedReason = export interface IChatToolInvocation { readonly presentation: IPreparedToolInvocation['presentation']; - readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; + readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatToolResourcesInvocationData; readonly originMessage: string | IMarkdownString | undefined; readonly invocationMessage: string | IMarkdownString; readonly pastTenseMessage: string | IMarkdownString | undefined; @@ -613,6 +613,8 @@ export namespace IChatToolInvocation { interface IChatToolInvocationCancelledState extends IChatToolInvocationStateBase, IChatToolInvocationPostStreamState { type: StateKind.Cancelled; reason: ToolConfirmKind.Denied | ToolConfirmKind.Skipped; + /** Optional message explaining why the tool was cancelled (e.g., from hook denial) */ + reasonMessage?: string | IMarkdownString; } export type State = @@ -793,7 +795,7 @@ export interface IToolResultOutputDetailsSerialized { */ export interface IChatToolInvocationSerialized { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatToolResourcesInvocationData; invocationMessage: string | IMarkdownString; originMessage: string | IMarkdownString | undefined; pastTenseMessage: string | IMarkdownString | undefined; @@ -840,6 +842,11 @@ export interface IChatTodoListContent { }>; } +export interface IChatToolResourcesInvocationData { + readonly kind: 'resources'; + readonly values: Array; +} + export interface IChatMcpServersStarting { readonly kind: 'mcpServersStarting'; readonly state?: IObservable; // not hydrated when serialized diff --git a/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts index d79d2f59199e2..98e095d42b09d 100644 --- a/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts +++ b/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts @@ -5,7 +5,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { URI } from '../../../../base/common/uri.js'; -import { HookTypeValue, IChatRequestHooks, IHookCommand } from './promptSyntax/hookSchema.js'; +import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from './promptSyntax/hookSchema.js'; import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; @@ -13,10 +13,66 @@ import { StopWatch } from '../../../../base/common/stopwatch.js'; import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../services/output/common/output.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { localize } from '../../../../nls.js'; +import { vEnum, vObj, vOptionalProp, vString } from '../../../../base/common/validation.js'; export const hooksOutputChannelId = 'hooksExecution'; const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks"); +//#region Hook Input Types + +/** + * Common properties added to all hook inputs by the execution service. + * These are built internally when executing hooks - callers don't provide them. + */ +export interface ICommonHookInput { + readonly timestamp: string; + readonly cwd: string; + readonly sessionId: string; + readonly hookEventName: string; +} + +//#endregion + +//#region PreToolUse Hook Types + +/** + * Input provided by the caller when invoking the preToolUse hook. + * This is the minimal set of data that the tool service knows about. + */ +export interface IPreToolUseCallerInput { + readonly toolName: string; + readonly toolArgs: unknown; +} + +/** + * Full input passed to the preToolUse hook. + * Combines the caller input with common hook properties. + */ +export interface IPreToolUseHookInput extends ICommonHookInput { + readonly toolName: string; + readonly toolArgs: unknown; +} + +/** + * Valid permission decisions for preToolUse hooks. + */ +export type PreToolUsePermissionDecision = 'allow' | 'deny'; + +/** + * Output from the preToolUse hook. + */ +export interface IPreToolUseHookOutput { + readonly permissionDecision: PreToolUsePermissionDecision; + readonly permissionDecisionReason?: string; +} + +const preToolUseOutputValidator = vObj({ + permissionDecision: vEnum('allow', 'deny'), + permissionDecisionReason: vOptionalProp(vString()), +}); + +//#endregion + export const enum HookResultKind { Success = 1, Error = 2 @@ -64,6 +120,13 @@ export interface IHooksExecutionService { * Execute hooks of the given type for the given session */ executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; + + /** + * Execute preToolUse hooks with typed input and validated output. + * The execution service builds the full hook input from the caller input plus session context. + * Output is optional, but if provided, it must conform to the expected schema. + */ + executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise; } export class HooksExecutionService implements IHooksExecutionService { @@ -99,7 +162,7 @@ export class HooksExecutionService implements IHooksExecutionService { this._ensureOutputChannel(); const channel = this._outputService.getChannel(hooksOutputChannelId); if (channel) { - channel.append(`[${new Date().toISOString()}] [#${requestId}] [${hookType}] ${message}\n`); + channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`); } } @@ -107,26 +170,40 @@ export class HooksExecutionService implements IHooksExecutionService { requestId: number, hookType: HookTypeValue, hookCommand: IHookCommand, - input: unknown, + sessionResource: URI, + callerInput: unknown, token: CancellationToken ): Promise { + // Build the common hook input properties + const commonInput: ICommonHookInput = { + timestamp: new Date().toISOString(), + cwd: hookCommand.cwd?.fsPath ?? '', + sessionId: sessionResource.toString(), + hookEventName: hookType, + }; + + // Merge common properties with caller-specific input + const fullInput = callerInput !== undefined && callerInput !== null && typeof callerInput === 'object' + ? { ...commonInput, ...callerInput } + : commonInput; + const hookCommandJson = JSON.stringify({ ...hookCommand, cwd: hookCommand.cwd?.fsPath }); this._log(requestId, hookType, `Running: ${hookCommandJson}`); - if (input !== undefined) { - this._log(requestId, hookType, `Input: ${JSON.stringify(input)}`); - } + // Log input with toolArgs truncated to avoid excessively long logs + const inputForLog = { ...fullInput as object, toolArgs: '...' }; + this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog)}`); const sw = StopWatch.create(); try { - const result = await this._proxy!.runHookCommand(hookCommand, input, token); - this._logResult(requestId, hookType, result, sw.elapsed()); + const result = await this._proxy!.runHookCommand(hookCommand, fullInput, token); + this._logResult(requestId, hookType, result, Math.round(sw.elapsed())); return result; } catch (err) { const errMessage = err instanceof Error ? err.message : String(err); - this._log(requestId, hookType, `Error in ${sw.elapsed()}ms: ${errMessage}`); + this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`); return { kind: HookResultKind.Error, result: errMessage }; } } @@ -178,10 +255,43 @@ export class HooksExecutionService implements IHooksExecutionService { const results: IHookResult[] = []; for (const hookCommand of hookCommands) { - const result = await this._runSingleHook(requestId, hookType, hookCommand, options?.input, token); + const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, token); results.push(result); } return results; } + + async executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise { + // Pass the caller input directly - common properties are added in _runSingleHook + const results = await this.executeHook(HookType.PreToolUse, sessionResource, { + input, + token: token ?? CancellationToken.None, + }); + + // Collect all valid outputs - "any deny wins" for security + let lastAllowOutput: IPreToolUseHookOutput | undefined; + for (const result of results) { + if (result.kind === HookResultKind.Success && typeof result.result === 'object') { + const validationResult = preToolUseOutputValidator.validate(result.result); + if (!validationResult.error) { + const output = validationResult.content; + // If any hook denies, return immediately with that denial + if (output.permissionDecision === 'deny') { + return output; + } + // Track the last allow in case we need to return it + if (output.permissionDecision === 'allow') { + lastAllowOutput = output; + } + } else { + // If validation fails, log a warning and continue to next result + this._logService.warn(`[HooksExecutionService] preToolUse hook output validation failed: ${validationResult.error.message}`); + } + } + } + + // Return the last allow output, or undefined if no valid outputs + return lastAllowOutput; + } } diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts index c042146940298..59319a7e77ecd 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -51,7 +51,15 @@ export class ChatToolInvocation implements IChatToolInvocation { * Use this when the tool call is beginning to stream partial input from the LM. */ public static createStreaming(options: IStreamingToolCallOptions): ChatToolInvocation { - return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.subagentInvocationId, undefined, true, options.chatRequestId); + return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.subagentInvocationId, undefined, { startInStreaming: true }, options.chatRequestId); + } + + /** + * Create a tool invocation already in cancelled state. + * Use this when a hook denies tool execution before it even starts. + */ + public static createCancelled(options: IStreamingToolCallOptions, parameters: unknown, reason: ToolConfirmKind.Denied | ToolConfirmKind.Skipped, reasonMessage?: string | IMarkdownString): ChatToolInvocation { + return new ChatToolInvocation(undefined, options.toolData, options.toolCallId, options.subagentInvocationId, parameters, { startInCancelled: true, cancelReason: reason, cancelReasonMessage: reasonMessage }, options.chatRequestId); } constructor( @@ -60,12 +68,17 @@ export class ChatToolInvocation implements IChatToolInvocation { public readonly toolCallId: string, subAgentInvocationId: string | undefined, parameters: unknown, - isStreaming: boolean = false, + startOptions: { startInStreaming?: boolean; startInCancelled?: boolean; cancelReason?: ToolConfirmKind.Denied | ToolConfirmKind.Skipped; cancelReasonMessage?: string | IMarkdownString } = {}, chatRequestId?: string ) { // For streaming invocations, use a default message until handleToolStream provides one - const defaultStreamingMessage = isStreaming ? localize('toolInvocationMessage', "Using \"{0}\"", toolData.displayName) : ''; - this.invocationMessage = preparedInvocation?.invocationMessage ?? defaultStreamingMessage; + let defaultMessage: string | IMarkdownString = ''; + if (startOptions.startInStreaming) { + defaultMessage = localize('toolInvocationMessage', "Using \"{0}\"", toolData.displayName); + } else if (startOptions.startInCancelled) { + defaultMessage = startOptions.cancelReasonMessage ?? localize('toolDeniedMessage', "Tool \"{0}\" was denied", toolData.displayName); + } + this.invocationMessage = preparedInvocation?.invocationMessage ?? defaultMessage; this.pastTenseMessage = preparedInvocation?.pastTenseMessage; this.originMessage = preparedInvocation?.originMessage; this.confirmationMessages = preparedInvocation?.confirmationMessages; @@ -77,7 +90,16 @@ export class ChatToolInvocation implements IChatToolInvocation { this.parameters = parameters; this.chatRequestId = chatRequestId; - if (isStreaming) { + if (startOptions.startInCancelled) { + // Start directly in cancelled state (e.g., when a hook denies execution) + this._state = observableValue(this, { + type: IChatToolInvocation.StateKind.Cancelled, + reason: startOptions.cancelReason ?? ToolConfirmKind.Denied, + reasonMessage: startOptions.cancelReasonMessage, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }); + } else if (startOptions.startInStreaming) { // Start in streaming state this._state = observableValue(this, { type: IChatToolInvocation.StateKind.Streaming, @@ -140,6 +162,27 @@ export class ChatToolInvocation implements IChatToolInvocation { this._streamingMessage.set(message, undefined); } + /** + * Cancel a streaming invocation directly (e.g., when preToolUse hook denies). + * Only works when in Streaming state. + * @returns true if the cancellation was applied, false if not in streaming state + */ + public cancelFromStreaming(reason: ToolConfirmKind.Denied | ToolConfirmKind.Skipped, reasonMessage?: string | IMarkdownString): boolean { + const currentState = this._state.get(); + if (currentState.type !== IChatToolInvocation.StateKind.Streaming) { + return false; // Only cancel from streaming state + } + + this._state.set({ + type: IChatToolInvocation.StateKind.Cancelled, + reason: reason, + reasonMessage: reasonMessage, + parameters: this.parameters, + confirmationMessages: this.confirmationMessages, + }, undefined); + return true; + } + /** * Transition from streaming state to prepared/executing state. * Called when the full tool call is ready. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 7731011cf47ca..1ec5dbb95b275 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -22,7 +22,7 @@ import { PromptsConfig } from './config/config.js'; import { isPromptOrInstructionsFile } from './config/promptFileLocations.js'; import { PromptsType } from './promptTypes.js'; import { ParsedPromptFile } from './promptFileParser.js'; -import { ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; +import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { ChatConfiguration, ChatModeKind } from '../constants.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; @@ -177,31 +177,33 @@ export class ComputeAutomaticInstructions { } private async _addAgentInstructions(variables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { - const useCopilotInstructionsFiles = this._configurationService.getValue(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES); - const useAgentMd = this._configurationService.getValue(PromptsConfig.USE_AGENT_MD); - if (!useCopilotInstructionsFiles && !useAgentMd) { - this._logService.trace(`[InstructionsContextComputer] No agent instructions files added (settings disabled).`); - return; - } + const logger = { + logInfo: (message: string) => this._logService.trace(`[InstructionsContextComputer] ${message}`) + }; + const allCandidates = await this._promptsService.listAgentInstructions(token, logger); const entries: ChatRequestVariableSet = new ChatRequestVariableSet(); - if (useCopilotInstructionsFiles) { - const files: URI[] = await this._promptsService.listCopilotInstructionsMDs(token); - for (const file of files) { - entries.add(toPromptFileVariableEntry(file, PromptFileVariableKind.Instruction, localize('instruction.file.reason.copilot', 'Automatically attached as setting {0} is enabled', PromptsConfig.USE_COPILOT_INSTRUCTION_FILES), true)); - telemetryEvent.agentInstructionsCount++; - this._logService.trace(`[InstructionsContextComputer] copilot-instruction.md files added: ${file.toString()}`); + const copilotEntries: ChatRequestVariableSet = new ChatRequestVariableSet(); + + for (const { uri, type } of allCandidates) { + const varEntry = toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, undefined, true); + entries.add(varEntry); + if (type === AgentFileType.copilotInstructionsMd) { + copilotEntries.add(varEntry); } - await this._addReferencedInstructions(entries, telemetryEvent, token); + + telemetryEvent.agentInstructionsCount++; + logger.logInfo(`Agent instruction file added: ${uri.toString()}`); } - if (useAgentMd) { - const files = await this._promptsService.listAgentMDs(token, false); - for (const file of files) { - entries.add(toPromptFileVariableEntry(file, PromptFileVariableKind.Instruction, localize('instruction.file.reason.agentsmd', 'Automatically attached as setting {0} is enabled', PromptsConfig.USE_AGENT_MD), true)); - telemetryEvent.agentInstructionsCount++; - this._logService.trace(`[InstructionsContextComputer] AGENTS.md files added: ${file.toString()}`); + + // Process referenced instructions from copilot files (maintaining original behavior) + if (copilotEntries.length > 0) { + await this._addReferencedInstructions(copilotEntries, telemetryEvent, token); + for (const entry of copilotEntries.asArray()) { + variables.add(entry); } } + for (const entry of entries.asArray()) { variables.add(entry); } @@ -263,7 +265,7 @@ export class ComputeAutomaticInstructions { if (readTool) { const searchNestedAgentMd = this._configurationService.getValue(PromptsConfig.USE_NESTED_AGENT_MD); - const agentsMdPromise = searchNestedAgentMd ? this._promptsService.findAgentMDsInWorkspace(token) : Promise.resolve([]); + const agentsMdPromise = searchNestedAgentMd ? this._promptsService.listNestedAgentMDs(token) : Promise.resolve([]); entries.push(''); entries.push('Here is a list of instruction files that contain rules for working with this codebase.'); @@ -294,7 +296,7 @@ export class ComputeAutomaticInstructions { } const agentsMdFiles = await agentsMdPromise; - for (const uri of agentsMdFiles) { + for (const { uri } of agentsMdFiles) { const folderName = this._labelService.getUriLabel(dirname(uri), { relative: true }); const description = folderName.trim().length === 0 ? localize('instruction.file.description.agentsmd.root', 'Instructions for the workspace') : localize('instruction.file.description.agentsmd.folder', 'Instructions for folder \'{0}\'', folderName); entries.push(''); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index f32674092b0fa..4b9621c55474d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -95,6 +95,11 @@ export namespace PromptsConfig { */ export const USE_NESTED_AGENT_MD = 'chat.useNestedAgentsMdFiles'; + /** + * Configuration key for the CLAUDE.md. + */ + export const USE_CLAUDE_MD = 'chat.useClaudeMdFile'; + /** * Configuration key for agent skills usage. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 48caf2eb37fa6..81f81c0d3d6fe 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -33,6 +33,21 @@ export const AGENT_FILE_EXTENSION = '.agent.md'; */ export const SKILL_FILENAME = 'SKILL.md'; +/** + * AGENT file name + */ +export const AGENT_MD_FILENAME = 'AGENTS.md'; + +/** + * Claude file name. + */ +export const CLAUDE_MD_FILENAME = 'CLAUDE.md'; + +/** + * Claude local file name. + */ +export const CLAUDE_LOCAL_MD_FILENAME = 'CLAUDE.local.md'; + /** * Default hook file name (case insensitive). */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index 8421a6bba75f1..ba37834afa7b4 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; -import { HookType, IHookCommand, normalizeHookTypeId, resolveHookCommand } from './hookSchema.js'; +import { HookType, IHookCommand, toHookType, resolveHookCommand } from './hookSchema.js'; /** * Maps Claude hook type names to our abstract HookType. @@ -13,7 +13,7 @@ import { HookType, IHookCommand, normalizeHookTypeId, resolveHookCommand } from */ export const CLAUDE_HOOK_TYPE_MAP: Record = { 'SessionStart': HookType.SessionStart, - 'UserPromptSubmit': HookType.UserPromptSubmitted, + 'UserPromptSubmit': HookType.UserPromptSubmit, 'PreToolUse': HookType.PreToolUse, 'PostToolUse': HookType.PostToolUse, 'SubagentStart': HookType.SubagentStart, @@ -92,7 +92,7 @@ export function parseClaudeHooks( for (const originalId of Object.keys(hooksObj)) { // Resolve Claude hook type name to our canonical HookType - const hookType = resolveClaudeHookType(originalId) ?? normalizeHookTypeId(originalId); + const hookType = resolveClaudeHookType(originalId) ?? toHookType(originalId); if (!hookType) { continue; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index 4da87267c90b1..38da83bcdd0bc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -5,8 +5,9 @@ import { URI } from '../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../base/common/path.js'; -import { HookType, IHookCommand, normalizeHookTypeId, resolveHookCommand } from './hookSchema.js'; +import { HookType, IHookCommand, toHookType, resolveHookCommand } from './hookSchema.js'; import { parseClaudeHooks } from './hookClaudeCompat.js'; +import { resolveCopilotCliHookType } from './hookCopilotCliCompat.js'; /** * Represents a hook source with its original and normalized properties. @@ -93,7 +94,7 @@ export function parseCopilotHooks( const hooksObj = hooks as Record; for (const originalId of Object.keys(hooksObj)) { - const hookType = normalizeHookTypeId(originalId); + const hookType = resolveCopilotCliHookType(originalId) ?? toHookType(originalId); if (!hookType) { continue; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts new file mode 100644 index 0000000000000..16ca9a825ae81 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HookType } from './hookSchema.js'; + +/** + * Maps Copilot CLI hook type names to our abstract HookType. + * Copilot CLI uses camelCase names. + */ +export const COPILOT_CLI_HOOK_TYPE_MAP: Record = { + 'sessionStart': HookType.SessionStart, + 'userPromptSubmitted': HookType.UserPromptSubmit, + 'preToolUse': HookType.PreToolUse, + 'postToolUse': HookType.PostToolUse, +}; + +/** + * Cached inverse mapping from HookType to Copilot CLI hook type name. + * Lazily computed on first access. + */ +let _hookTypeToCopilotCliName: Map | undefined; + +function getHookTypeToCopilotCliNameMap(): Map { + if (!_hookTypeToCopilotCliName) { + _hookTypeToCopilotCliName = new Map(); + for (const [copilotCliName, hookType] of Object.entries(COPILOT_CLI_HOOK_TYPE_MAP)) { + _hookTypeToCopilotCliName.set(hookType, copilotCliName); + } + } + return _hookTypeToCopilotCliName; +} + +/** + * Resolves a Copilot CLI hook type name to our abstract HookType. + */ +export function resolveCopilotCliHookType(name: string): HookType | undefined { + return COPILOT_CLI_HOOK_TYPE_MAP[name]; +} + +/** + * Gets the Copilot CLI hook type name for a given abstract HookType. + * Returns undefined if the hook type is not supported in Copilot CLI. + */ +export function getCopilotCliHookTypeName(hookType: HookType): string | undefined { + return getHookTypeToCopilotCliNameMap().get(hookType); +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 5d5623a113de0..2fde3773eaaa8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -14,14 +14,13 @@ import { untildify } from '../../../../../base/common/labels.js'; * Enum of available hook types that can be configured in hooks.json */ export enum HookType { - SessionStart = 'sessionStart', - UserPromptSubmitted = 'userPromptSubmitted', - PreToolUse = 'preToolUse', - PostToolUse = 'postToolUse', - PostToolUseFailure = 'postToolUseFailure', - SubagentStart = 'subagentStart', - SubagentStop = 'subagentStop', - Stop = 'stop', + SessionStart = 'SessionStart', + UserPromptSubmit = 'UserPromptSubmit', + PreToolUse = 'PreToolUse', + PostToolUse = 'PostToolUse', + SubagentStart = 'SubagentStart', + SubagentStop = 'SubagentStop', + Stop = 'Stop', } /** @@ -39,9 +38,9 @@ export const HOOK_TYPES = [ description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins or when resuming an existing session.") }, { - id: HookType.UserPromptSubmitted, - label: nls.localize('hookType.userPromptSubmitted.label', "User Prompt Submitted"), - description: nls.localize('hookType.userPromptSubmitted.description', "Executed when the user submits a prompt to the agent.") + id: HookType.UserPromptSubmit, + label: nls.localize('hookType.userPromptSubmit.label', "User Prompt Submit"), + description: nls.localize('hookType.userPromptSubmit.description', "Executed when the user submits a prompt to the agent.") }, { id: HookType.PreToolUse, @@ -53,11 +52,6 @@ export const HOOK_TYPES = [ label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") }, - { - id: HookType.PostToolUseFailure, - label: nls.localize('hookType.postToolUseFailure.label', "Post-Tool Use Failure"), - description: nls.localize('hookType.postToolUseFailure.description', "Executed after a tool completes execution with a failure.") - }, { id: HookType.SubagentStart, label: nls.localize('hookType.subagentStart.label', "Subagent Start"), @@ -98,10 +92,9 @@ export interface IHookCommand { */ export interface IChatRequestHooks { readonly [HookType.SessionStart]?: readonly IHookCommand[]; - readonly [HookType.UserPromptSubmitted]?: readonly IHookCommand[]; + readonly [HookType.UserPromptSubmit]?: readonly IHookCommand[]; readonly [HookType.PreToolUse]?: readonly IHookCommand[]; readonly [HookType.PostToolUse]?: readonly IHookCommand[]; - readonly [HookType.PostToolUseFailure]?: readonly IHookCommand[]; readonly [HookType.SubagentStart]?: readonly IHookCommand[]; readonly [HookType.SubagentStop]?: readonly IHookCommand[]; readonly [HookType.Stop]?: readonly IHookCommand[]; @@ -176,37 +169,33 @@ export const hookFileSchema: IJSONSchema = { hooks: { type: 'object', description: nls.localize('hookFile.hooks', 'Hook definitions organized by type.'), - additionalProperties: false, + additionalProperties: true, properties: { - sessionStart: { + SessionStart: { ...hookArraySchema, description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins or when resuming an existing session. Use to initialize environments, log session starts, validate project state, or set up temporary resources.') }, - userPromptSubmitted: { + UserPromptSubmit: { ...hookArraySchema, - description: nls.localize('hookFile.userPromptSubmitted', 'Executed when the user submits a prompt to the agent. Use to log user requests for auditing and usage analysis.') + description: nls.localize('hookFile.userPromptSubmit', 'Executed when the user submits a prompt to the agent. Use to log user requests for auditing and usage analysis.') }, - preToolUse: { + PreToolUse: { ...hookArraySchema, description: nls.localize('hookFile.preToolUse', 'Executed before the agent uses any tool (such as bash, edit, view). This is the most powerful hook as it can approve or deny tool executions. Use to block dangerous commands, enforce security policies, require approval for sensitive operations, or log tool usage.') }, - postToolUse: { + PostToolUse: { ...hookArraySchema, description: nls.localize('hookFile.postToolUse', 'Executed after a tool completes execution successfully. Use to log execution results, track usage statistics, generate audit trails, or monitor performance.') }, - postToolUseFailure: { - ...hookArraySchema, - description: nls.localize('hookFile.postToolUseFailure', 'Executed after a tool completes execution with a failure. Use to log errors, send failure alerts, or trigger recovery actions.') - }, - subagentStart: { + SubagentStart: { ...hookArraySchema, description: nls.localize('hookFile.subagentStart', 'Executed when a subagent is started. Use to log subagent spawning, track nested agent usage, or initialize subagent-specific resources.') }, - subagentStop: { + SubagentStop: { ...hookArraySchema, description: nls.localize('hookFile.subagentStop', 'Executed when a subagent stops. Use to log subagent completion, cleanup subagent resources, or aggregate subagent results.') }, - stop: { + Stop: { ...hookArraySchema, description: nls.localize('hookFile.stop', 'Executed when the agent session stops. Use to cleanup resources, generate final reports, or send completion notifications.') } @@ -251,40 +240,14 @@ export const HOOK_FILE_GLOB = 'hooks/hooks.json'; /** * Normalizes a raw hook type identifier to the canonical HookType enum value. - * Supports alternative casing and naming conventions from different tools: - * - Claude Code: PreToolUse, PostToolUse, SessionStart, Stop, SubagentStart, SubagentStop, UserPromptSubmit - * - GitHub Copilot: sessionStart, userPromptSubmitted, preToolUse, postToolUse, etc. - * - * @see https://docs.anthropic.com/en/docs/claude-code/hooks - * @see https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks#types-of-hooks + * Only matches exact enum values. For tool-specific naming conventions (e.g., Claude, Copilot CLI), + * use the corresponding compat module's resolver function. */ -export function normalizeHookTypeId(rawHookTypeId: string): HookType | undefined { - // Check if it's already a canonical HookType value +export function toHookType(rawHookTypeId: string): HookType | undefined { if (Object.values(HookType).includes(rawHookTypeId as HookType)) { return rawHookTypeId as HookType; } - - // Handle alternative names from Claude Code - switch (rawHookTypeId) { - case 'SessionStart': - return HookType.SessionStart; - case 'UserPromptSubmit': - return HookType.UserPromptSubmitted; - case 'PreToolUse': - return HookType.PreToolUse; - case 'PostToolUse': - return HookType.PostToolUse; - case 'PostToolUseFailure': - return HookType.PostToolUseFailure; - case 'SubagentStart': - return HookType.SubagentStart; - case 'SubagentStop': - return HookType.SubagentStop; - case 'Stop': - return HookType.Stop; - default: - return undefined; - } + return undefined; } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index fdb4790ac6ff5..feabfd922fed1 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -221,6 +221,32 @@ export interface IAgentSkill { readonly description: string | undefined; } +/** + * Type of agent instruction file. + */ +export enum AgentFileType { + agentsMd = 'agentsMd', + claudeMd = 'claudeMd', + copilotInstructionsMd = 'copilotInstructionsMd', +} + +/** + * Represents a resolved agent instruction file with its real path for duplicate detection. + * Used by listAgentInstructions to filter out symlinks pointing to the same file. + */ +export interface IResolvedAgentFile { + readonly uri: URI; + /** + * The real path of the file, if it is a symlink. + */ + readonly realPath: URI | undefined; + readonly type: AgentFileType; +} + +export interface Logger { + logInfo(message: string): void; +} + /** * Reason why a prompt file was skipped during discovery. */ @@ -343,20 +369,15 @@ export interface IPromptsService extends IDisposable { getPromptLocationLabel(promptPath: IPromptPath): string; /** - * Gets list of all AGENTS.md files in the workspace. - */ - findAgentMDsInWorkspace(token: CancellationToken): Promise; - - /** - * Gets list of AGENTS.md files. - * @param includeNested Whether to include AGENTS.md files from subfolders, or only from the root. + * Gets list of AGENTS.md files, including optionally nested ones from subfolders. */ - listAgentMDs(token: CancellationToken, includeNested: boolean): Promise; + listNestedAgentMDs(token: CancellationToken): Promise; /** - * Gets list of .github/copilot-instructions.md files. + * Gets combined list of agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md). + * Combines results from listAgentMDs (non-nested), listClaudeMDs, and listCopilotInstructionsMDs. */ - listCopilotInstructionsMDs(token: CancellationToken): Promise; + listAgentInstructions(token: CancellationToken, logger?: Logger): Promise; /** * For a chat mode file URI, return the name of the agent file that it should use. 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 d37fa5f5fd36b..43d627c1578f0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -8,7 +8,7 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; -import { basename, dirname, isEqual } from '../../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js'; import { type ITextModel } from '../../../../../../editor/common/model.js'; @@ -27,11 +27,11 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; -import { getCleanPromptName, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; +import { AGENT_MD_FILENAME, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, getCleanPromptName, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility } from './promptsService.js'; +import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; @@ -96,6 +96,11 @@ export class PromptsService extends Disposable implements IPromptsService { */ private readonly cachedHooks: CachedPromise; + /** + * Cached skills. Caching only happens if the `onDidChangeSkills` event is used. + */ + private readonly cachedSkills: CachedPromise; + /** * Cache for parsed prompt files keyed by URI. * The number in the returned tuple is textModel.getVersionId(), which is an internal VS Code counter that increments every time the text model's content changes. @@ -156,7 +161,16 @@ export class PromptsService extends Disposable implements IPromptsService { this.cachedSlashCommands = this._register(new CachedPromise( (token) => this.computePromptSlashCommands(token), - () => Event.any(this.getFileLocatorEvent(PromptsType.prompt), Event.filter(modelChangeEvent, e => e.promptType === PromptsType.prompt)) + () => Event.any( + this.getFileLocatorEvent(PromptsType.prompt), + this.getFileLocatorEvent(PromptsType.skill), + Event.filter(modelChangeEvent, e => e.promptType === PromptsType.prompt), + Event.filter(modelChangeEvent, e => e.promptType === PromptsType.skill)), + )); + + this.cachedSkills = this._register(new CachedPromise( + (token) => this.computeAgentSkills(token), + () => Event.any(this.getFileLocatorEvent(PromptsType.skill), Event.filter(modelChangeEvent, e => e.promptType === PromptsType.skill)) )); this.cachedHooks = this._register(new CachedPromise( @@ -165,6 +179,7 @@ export class PromptsService extends Disposable implements IPromptsService { )); // Hack: Subscribe to activate caching (CachedPromise only caches when onDidChange has listeners) + this._register(this.cachedSkills.onDidChange(() => { })); this._register(this.cachedHooks.onDidChange(() => { })); } @@ -253,6 +268,8 @@ export class PromptsService extends Disposable implements IPromptsService { this.cachedSlashCommands.refresh(); } else if (type === PromptsType.skill) { this.cachedFileLocations[PromptsType.skill] = undefined; + this.cachedSkills.refresh(); + this.cachedSlashCommands.refresh(); } })); } @@ -268,6 +285,8 @@ export class PromptsService extends Disposable implements IPromptsService { this.cachedSlashCommands.refresh(); } else if (type === PromptsType.skill) { this.cachedFileLocations[PromptsType.skill] = undefined; + this.cachedSkills.refresh(); + this.cachedSlashCommands.refresh(); } disposables.add({ @@ -285,6 +304,8 @@ export class PromptsService extends Disposable implements IPromptsService { this.cachedSlashCommands.refresh(); } else if (type === PromptsType.skill) { this.cachedFileLocations[PromptsType.skill] = undefined; + this.cachedSkills.refresh(); + this.cachedSlashCommands.refresh(); } } } @@ -433,7 +454,10 @@ export class PromptsService extends Disposable implements IPromptsService { private async computePromptSlashCommands(token: CancellationToken): Promise { const promptFiles = await this.listPromptFiles(PromptsType.prompt, token); - const details = await Promise.all(promptFiles.map(async promptPath => { + const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); + const skills = useAgentSkills ? await this.listPromptFiles(PromptsType.skill, token) : []; + const slashCommandFiles = [...promptFiles, ...skills]; + const details = await Promise.all(slashCommandFiles.map(async promptPath => { try { const parsedPromptFile = await this.parseNew(promptPath.uri, token); return this.asChatPromptSlashCommand(parsedPromptFile, promptPath); @@ -629,6 +653,10 @@ export class PromptsService extends Disposable implements IPromptsService { case PromptsType.prompt: this.cachedSlashCommands.refresh(); break; + case PromptsType.skill: + this.cachedSkills.refresh(); + this.cachedSlashCommands.refresh(); + break; } }; flushCachesIfRequired(); @@ -651,30 +679,91 @@ export class PromptsService extends Disposable implements IPromptsService { } } - findAgentMDsInWorkspace(token: CancellationToken): Promise { - return this.fileLocator.findAgentMDsInWorkspace(token); + public async listNestedAgentMDs(token: CancellationToken): Promise { + const useAgentMD = this.configurationService.getValue(PromptsConfig.USE_AGENT_MD); + if (!useAgentMD) { + return []; + } + const useNestedAgentMD = this.configurationService.getValue(PromptsConfig.USE_NESTED_AGENT_MD); + if (useNestedAgentMD) { + return await this.fileLocator.findAgentMDsInWorkspace(token); + } + return []; } - public async listAgentMDs(token: CancellationToken, includeNested: boolean): Promise { + public async listAgentMDs(token: CancellationToken, logger: Logger | undefined): Promise { const useAgentMD = this.configurationService.getValue(PromptsConfig.USE_AGENT_MD); if (!useAgentMD) { + logger?.logInfo('Agent MD files are disabled via configuration.'); return []; } - if (includeNested) { - return await this.fileLocator.findAgentMDsInWorkspace(token); - } else { - return await this.fileLocator.findAgentMDsInWorkspaceRoots(token); + return await this.fileLocator.findFilesInWorkspaceRoots(AGENT_MD_FILENAME, undefined, AgentFileType.agentsMd, token); + } + + public async listClaudeMDs(token: CancellationToken, logger: Logger | undefined): Promise { + // see https://code.claude.com/docs/en/memory + const useClaudeMD = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_MD); + if (!useClaudeMD) { + logger?.logInfo('Claude MD files are disabled via configuration.'); + return []; } + const results: IResolvedAgentFile[] = []; + const userHome = await this.pathService.userHome(); + const userClaudeFolder = joinPath(userHome, '.claude'); + await Promise.all([ + this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_MD_FILENAME, undefined, AgentFileType.claudeMd, token, results), // in workspace roots + this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_LOCAL_MD_FILENAME, undefined, AgentFileType.claudeMd, token, results), // CLAUDE.local in workspace roots + this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_MD_FILENAME, '.claude', AgentFileType.claudeMd, token, results), // in workspace/.claude folders + this.fileLocator.findFilesInRoots([userClaudeFolder], CLAUDE_MD_FILENAME, AgentFileType.claudeMd, token, results) // in ~/.claude folder + ]); + return results.sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())); } - public async listCopilotInstructionsMDs(token: CancellationToken): Promise { + public async listCopilotInstructionsMDs(token: CancellationToken, logger: Logger | undefined): Promise { const useCopilotInstructionsFiles = this.configurationService.getValue(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES); if (!useCopilotInstructionsFiles) { + logger?.logInfo('Copilot instructions files are disabled via configuration.'); return []; } return await this.fileLocator.findCopilotInstructionsMDsInWorkspace(token); } + public async listAgentInstructions(token: CancellationToken, logger: Logger | undefined): Promise { + const [agentMDs, claudeMDs, copilotInstructionsMDs] = await Promise.all([ + this.listAgentMDs(token, logger), + this.listClaudeMDs(token, logger), + this.listCopilotInstructionsMDs(token, logger) + ]); + if (token.isCancellationRequested) { + return []; + } + // first look at non-symlinked files, then add symlinks only if target not already included + const seenFileURI = new ResourceSet(); + const symlinks: (IResolvedAgentFile & { realPath: URI })[] = []; + const result: IResolvedAgentFile[] = []; + const add = (file: IResolvedAgentFile) => { + if (file.realPath) { + symlinks.push(file as IResolvedAgentFile & { realPath: URI }); + } else { + result.push(file); + seenFileURI.add(file.uri); + } + return true; + }; + agentMDs.forEach(add); + claudeMDs.forEach(add); + copilotInstructionsMDs.forEach(add); + for (const symlink of symlinks) { + if (seenFileURI.has(symlink.realPath)) { + logger?.logInfo(`Skipping symlinked agent instructions file ${symlink.uri} as target already included: ${symlink.realPath}`); + } else { + result.push(symlink); + seenFileURI.add(symlink.realPath); + } + } + return result; + } + public getAgentFileURIFromModeFile(oldURI: URI): URI | undefined { return this.fileLocator.getAgentFileURIFromModeFile(oldURI); } @@ -783,12 +872,20 @@ export class PromptsService extends Disposable implements IPromptsService { return sanitized; } + public get onDidChangeSkills(): Event { + return this.cachedSkills.onDidChange; + } + public async findAgentSkills(token: CancellationToken): Promise { const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); if (!useAgentSkills) { return undefined; } + return this.cachedSkills.get(token); + } + + private async computeAgentSkills(token: CancellationToken): Promise { const { files, skillsBySource } = await this.computeSkillDiscoveryInfo(token); // Extract loaded skills @@ -905,10 +1002,9 @@ export class PromptsService extends Disposable implements IPromptsService { const collectedHooks: Record = { [HookType.SessionStart]: [], - [HookType.UserPromptSubmitted]: [], + [HookType.UserPromptSubmit]: [], [HookType.PreToolUse]: [], [HookType.PostToolUse]: [], - [HookType.PostToolUseFailure]: [], [HookType.SubagentStart]: [], [HookType.SubagentStop]: [], [HookType.Stop]: [], @@ -941,16 +1037,9 @@ export class PromptsService extends Disposable implements IPromptsService { } // Build the result, only including hook types that have entries - const result: IChatRequestHooks = { - ...(collectedHooks[HookType.SessionStart].length > 0 && { sessionStart: collectedHooks[HookType.SessionStart] }), - ...(collectedHooks[HookType.UserPromptSubmitted].length > 0 && { userPromptSubmitted: collectedHooks[HookType.UserPromptSubmitted] }), - ...(collectedHooks[HookType.PreToolUse].length > 0 && { preToolUse: collectedHooks[HookType.PreToolUse] }), - ...(collectedHooks[HookType.PostToolUse].length > 0 && { postToolUse: collectedHooks[HookType.PostToolUse] }), - ...(collectedHooks[HookType.PostToolUseFailure].length > 0 && { postToolUseFailure: collectedHooks[HookType.PostToolUseFailure] }), - ...(collectedHooks[HookType.SubagentStart].length > 0 && { subagentStart: collectedHooks[HookType.SubagentStart] }), - ...(collectedHooks[HookType.SubagentStop].length > 0 && { subagentStop: collectedHooks[HookType.SubagentStop] }), - ...(collectedHooks[HookType.Stop].length > 0 && { stop: collectedHooks[HookType.Stop] }), - }; + const result: IChatRequestHooks = Object.fromEntries( + Object.entries(collectedHooks).filter(([_, commands]) => commands.length > 0) + ) as IChatRequestHooks; this.logger.trace(`[PromptsService] Collected hooks: ${JSON.stringify(Object.keys(result))}`); return result; @@ -967,6 +1056,8 @@ export class PromptsService extends Disposable implements IPromptsService { return this.getPromptSlashCommandDiscoveryInfo(token); } else if (type === PromptsType.instructions) { return this.getInstructionsDiscoveryInfo(token); + } else if (type === PromptsType.hook) { + return this.getHookDiscoveryInfo(token); } return { type, files }; @@ -1190,6 +1281,70 @@ export class PromptsService extends Disposable implements IPromptsService { return { type: PromptsType.instructions, files }; } + + private async getHookDiscoveryInfo(token: CancellationToken): Promise { + const files: IPromptFileDiscoveryResult[] = []; + + const hookFiles = await this.listPromptFiles(PromptsType.hook, token); + for (const promptPath of hookFiles) { + const uri = promptPath.uri; + const storage = promptPath.storage; + const extensionId = promptPath.extension?.identifier?.value; + const name = basename(uri); + + try { + // Try to parse the JSON to validate it + const content = await this.fileService.readFile(uri); + const json = JSON.parse(content.value.toString()); + + // Validate it's an object + if (!json || typeof json !== 'object') { + files.push({ + uri, + storage, + status: 'skipped', + skipReason: 'parse-error', + errorMessage: 'Invalid hooks file: must be a JSON object', + name, + extensionId + }); + continue; + } + + // Validate version field for Copilot hooks.json format + const filename = basename(uri).toLowerCase(); + if (filename === 'hooks.json' && json.version !== 1) { + files.push({ + uri, + storage, + status: 'skipped', + skipReason: 'parse-error', + errorMessage: json.version === undefined + ? 'Missing version field (expected: 1)' + : `Invalid version: ${json.version} (expected: 1)`, + name, + extensionId + }); + continue; + } + + // File is valid + files.push({ uri, storage, status: 'loaded', name, extensionId }); + } catch (e) { + files.push({ + uri, + storage, + status: 'skipped', + skipReason: 'parse-error', + errorMessage: e instanceof Error ? e.message : String(e), + name, + extensionId + }); + } + } + + return { type: PromptsType.hook, files }; + } } // helpers diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 731c55576a6f8..38146a5809d32 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -19,7 +19,7 @@ import { Schemas } from '../../../../../../base/common/network.js'; import { getExcludes, IFileQuery, ISearchConfiguration, ISearchService, QueryType } from '../../../../../services/search/common/search.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../../../base/common/errors.js'; -import { PromptsStorage } from '../service/promptsService.js'; +import { AgentFileType, IResolvedAgentFile, PromptsStorage } from '../service/promptsService.js'; import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; @@ -507,15 +507,16 @@ export class PromptFilesLocator { return []; } - public async findCopilotInstructionsMDsInWorkspace(token: CancellationToken): Promise { - const result: URI[] = []; + public async findCopilotInstructionsMDsInWorkspace(token: CancellationToken): Promise { + const result: IResolvedAgentFile[] = []; const { folders } = this.workspaceService.getWorkspace(); for (const folder of folders) { const file = joinPath(folder.uri, `.github/` + COPILOT_CUSTOM_INSTRUCTIONS_FILENAME); try { const stat = await this.fileService.stat(file); if (stat.isFile) { - result.push(file); + const realPath = stat.isSymbolicLink ? await this.fileService.realpath(file) : undefined; + result.push({ uri: file, realPath, type: AgentFileType.copilotInstructionsMd }); } } catch (error) { this.logService.trace(`[PromptFilesLocator] Skipping copilot-instructions.md at ${file.toString()}: ${error}`); @@ -527,12 +528,12 @@ export class PromptFilesLocator { /** * Gets list of `AGENTS.md` files anywhere in the workspace. */ - public async findAgentMDsInWorkspace(token: CancellationToken): Promise { + public async findAgentMDsInWorkspace(token: CancellationToken): Promise { const result = await Promise.all(this.workspaceService.getWorkspace().folders.map(folder => this.findAgentMDsInFolder(folder.uri, token))); return result.flat(1); } - private async findAgentMDsInFolder(folder: URI, token: CancellationToken): Promise { + private async findAgentMDsInFolder(folder: URI, token: CancellationToken): Promise { // Check if a FileSearchProvider is available for this scheme if (this.searchService.schemeHasFileSearchProvider(folder.scheme)) { // Use the search service if a FileSearchProvider is available @@ -552,7 +553,13 @@ export class PromptFilesLocator { if (token.isCancellationRequested) { return []; } - return searchResult.results.map(r => r.resource); + // Resolve real paths for duplicate detection + const results: IResolvedAgentFile[] = []; + for (const r of searchResult.results) { + const realPath = undefined; // We can skip realpath resolution here for performance; duplicates can be handled later if needed + results.push({ uri: r.resource, realPath, type: AgentFileType.agentsMd }); + } + return results; } catch (e) { if (!isCancellationError(e)) { throw e; @@ -569,8 +576,8 @@ export class PromptFilesLocator { * Recursively traverses a folder using the file service to find AGENTS.md files. * This is used as a fallback when no FileSearchProvider is available for the scheme. */ - private async findAgentMDsUsingFileService(folder: URI, token: CancellationToken): Promise { - const result: URI[] = []; + private async findAgentMDsUsingFileService(folder: URI, token: CancellationToken): Promise { + const result: IResolvedAgentFile[] = []; const agentsMdFileName = 'agents.md'; const traverse = async (uri: URI): Promise => { @@ -581,7 +588,8 @@ export class PromptFilesLocator { try { const stat = await this.fileService.resolve(uri); if (stat.isFile && stat.name.toLowerCase() === agentsMdFileName) { - result.push(stat.resource); + const realPath = stat.isSymbolicLink ? await this.fileService.realpath(stat.resource) : undefined; + result.push({ uri: stat.resource, realPath, type: AgentFileType.agentsMd }); } else if (stat.isDirectory && stat.children) { // Recursively traverse subdirectories for (const child of stat.children) { @@ -599,17 +607,28 @@ export class PromptFilesLocator { } /** - * Gets list of `AGENTS.md` files only at the root workspace folder(s). + * Gets list of files at the root workspace folder(s). */ - public async findAgentMDsInWorkspaceRoots(token: CancellationToken): Promise { - const result: URI[] = []; + public async findFilesInWorkspaceRoots(fileName: string, folder: string | undefined, type: AgentFileType, token: CancellationToken, result: IResolvedAgentFile[] = []): Promise { const { folders } = this.workspaceService.getWorkspace(); - const resolvedRoots = await this.fileService.resolveAll(folders.map(f => ({ resource: f.uri }))); + if (folder) { + return this.findFilesInRoots(folders.map(f => joinPath(f.uri, folder)), fileName, type, token, result); + } + return this.findFilesInRoots(folders.map(f => f.uri), fileName, type, token, result); + } + + public async findFilesInRoots(roots: URI[], fileName: string, type: AgentFileType, token: CancellationToken, result: IResolvedAgentFile[] = []): Promise { + const fileNameLower = fileName.toLowerCase(); + const resolvedRoots = await this.fileService.resolveAll(roots.map(uri => ({ resource: uri }))); + if (token.isCancellationRequested) { + return result; + } for (const root of resolvedRoots) { if (root.success && root.stat?.children) { - const agentMd = root.stat.children.find(c => c.isFile && c.name.toLowerCase() === 'agents.md'); - if (agentMd) { - result.push(agentMd.resource); + const file = root.stat.children.find(c => c.isFile && c.name.toLowerCase() === fileNameLower); + if (file) { + const realPath = file.isSymbolicLink ? await this.fileService.realpath(file.resource) : undefined; + result.push({ uri: file.resource, realPath, type }); } } } diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index 728883ddfe144..7b2b9af22783a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { Range } from '../../../../../../editor/common/core/range.js'; import { Location } from '../../../../../../editor/common/languages.js'; import { getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; -import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData } from '../../../common/chatService/chatService.js'; +import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; suite('ChatResponseAccessibleView', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -152,6 +152,56 @@ suite('ChatResponseAccessibleView', () => { assert.ok(result.includes('key')); assert.ok(result.includes('value')); }); + + test('returns resources list for resources data with URIs', () => { + const resourcesData: IChatToolResourcesInvocationData = { + kind: 'resources', + values: [ + URI.file('/path/to/file1.ts'), + URI.file('/path/to/file2.ts') + ] + }; + const result = getToolSpecificDataDescription(resourcesData); + assert.ok(result.includes('file1.ts')); + assert.ok(result.includes('file2.ts')); + }); + + test('returns resources list for resources data with Locations', () => { + const resourcesData: IChatToolResourcesInvocationData = { + kind: 'resources', + values: [ + { uri: URI.file('/path/to/file1.ts'), range: new Range(1, 1, 10, 1) }, + { uri: URI.file('/path/to/file2.ts'), range: new Range(5, 1, 15, 1) } + ] + }; + const result = getToolSpecificDataDescription(resourcesData); + assert.ok(result.includes('file1.ts')); + assert.ok(result.includes(':1')); // Line number included for Locations + assert.ok(result.includes('file2.ts')); + assert.ok(result.includes(':5')); // Line number included for Locations + }); + + test('returns resources list for mixed URIs and Locations', () => { + const resourcesData: IChatToolResourcesInvocationData = { + kind: 'resources', + values: [ + URI.file('/path/to/file1.ts'), + { uri: URI.file('/path/to/file2.ts'), range: new Range(10, 1, 20, 1) } + ] + }; + const result = getToolSpecificDataDescription(resourcesData); + assert.ok(result.includes('file1.ts')); + assert.ok(result.includes('file2.ts')); + assert.ok(result.includes(':10')); // Line number for Location only + }); + + test('returns empty for empty resources array', () => { + const resourcesData: IChatToolResourcesInvocationData = { + kind: 'resources', + values: [] + }; + assert.strictEqual(getToolSpecificDataDescription(resourcesData), ''); + }); }); suite('getResultDetailsDescription', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts index 105de0584378b..38cca58035774 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatCustomizationDiagnosticsAction.test.ts @@ -16,7 +16,8 @@ suite('formatStatusOutput', () => { const emptySpecialFiles = { agentsMd: { enabled: false, files: [] }, - copilotInstructions: { enabled: false, files: [] } + copilotInstructions: { enabled: false, files: [] }, + claudeMd: { enabled: false, files: [] } }; function createPath(displayPath: string, exists: boolean, storage: PromptsStorage = PromptsStorage.local, isDefault = true): IPathInfo { @@ -213,7 +214,8 @@ suite('formatStatusOutput', () => { const specialFiles = { agentsMd: { enabled: false, files: [] }, - copilotInstructions: { enabled: true, files: [URI.file('/workspace/.github/copilot-instructions.md')] } + copilotInstructions: { enabled: true, files: [URI.file('/workspace/.github/copilot-instructions.md')] }, + claudeMd: { enabled: false, files: [] } }; const output = formatStatusOutput(statusInfos, specialFiles, []); @@ -244,7 +246,8 @@ suite('formatStatusOutput', () => { const specialFiles = { agentsMd: { enabled: true, files: [URI.file('/workspace/AGENTS.md'), URI.file('/workspace/docs/AGENTS.md')] }, - copilotInstructions: { enabled: false, files: [] } + copilotInstructions: { enabled: false, files: [] }, + claudeMd: { enabled: false, files: [] } }; const output = formatStatusOutput(statusInfos, specialFiles, []); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts index 03257775f1eef..1496089cf8f2b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -34,6 +34,319 @@ suite('hookUtils', () => { suite('simple format', () => { const simpleFormat = `{ "version": 1, + "hooks": { + "SessionStart": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ], + "UserPromptSubmit": [ + { + "type": "command", + "command": "echo foo > test.derp" + } + ] + } +}`; + + test('finds first command in SessionStart', () => { + const result = findHookCommandSelection(simpleFormat, 'SessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo first'); + assert.deepStrictEqual(result, { + startLineNumber: 7, + startColumn: 17, + endLineNumber: 7, + endColumn: 27 + }); + }); + + test('finds second command in SessionStart', () => { + const result = findHookCommandSelection(simpleFormat, 'SessionStart', 1, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo second'); + assert.deepStrictEqual(result, { + startLineNumber: 11, + startColumn: 17, + endLineNumber: 11, + endColumn: 28 + }); + }); + + test('finds command in UserPromptSubmit', () => { + const result = findHookCommandSelection(simpleFormat, 'UserPromptSubmit', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo foo > test.derp'); + assert.deepStrictEqual(result, { + startLineNumber: 17, + startColumn: 17, + endLineNumber: 17, + endColumn: 37 + }); + }); + + test('returns undefined for out of bounds index', () => { + const result = findHookCommandSelection(simpleFormat, 'SessionStart', 5, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for non-existent hook type', () => { + const result = findHookCommandSelection(simpleFormat, 'nonExistent', 0, 'command'); + assert.strictEqual(result, undefined); + }); + }); + + suite('nested matcher format', () => { + const nestedFormat = `{ + "forceLoginMethod": "console", + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "echo 'foobarbaz5' > ~/foobarbaz.txt" + } + ] + } + ] + } +}`; + + test('finds command inside nested hooks', () => { + const result = findHookCommandSelection(nestedFormat, 'UserPromptSubmit', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(nestedFormat, result), 'echo \'foobarbaz5\' > ~/foobarbaz.txt'); + assert.deepStrictEqual(result, { + startLineNumber: 10, + startColumn: 19, + endLineNumber: 10, + endColumn: 54 + }); + }); + + test('returns undefined for non-existent field name', () => { + const result = findHookCommandSelection(nestedFormat, 'UserPromptSubmit', 0, 'bash'); + assert.strictEqual(result, undefined); + }); + }); + + suite('mixed format with multiple nested hooks', () => { + const mixedFormat = `{ + "hooks": { + "PreToolUse": [ + { + "matcher": "edit_file", + "hooks": [ + { + "type": "command", + "command": "first nested" + }, + { + "type": "command", + "command": "second nested" + } + ] + }, + { + "type": "command", + "command": "simple after nested" + } + ] + } +}`; + + test('finds first command in first nested hooks array', () => { + const result = findHookCommandSelection(mixedFormat, 'PreToolUse', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'first nested'); + assert.deepStrictEqual(result, { + startLineNumber: 9, + startColumn: 19, + endLineNumber: 9, + endColumn: 31 + }); + }); + + test('finds second command in first nested hooks array', () => { + const result = findHookCommandSelection(mixedFormat, 'PreToolUse', 1, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'second nested'); + assert.deepStrictEqual(result, { + startLineNumber: 13, + startColumn: 19, + endLineNumber: 13, + endColumn: 32 + }); + }); + + test('finds simple command after nested structure', () => { + const result = findHookCommandSelection(mixedFormat, 'PreToolUse', 2, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'simple after nested'); + assert.deepStrictEqual(result, { + startLineNumber: 19, + startColumn: 17, + endLineNumber: 19, + endColumn: 36 + }); + }); + }); + + suite('bash and powershell fields', () => { + const platformSpecificFormat = `{ + "hooks": { + "SessionStart": [ + { + "type": "command", + "bash": "echo hello from bash", + "powershell": "Write-Host hello" + } + ] + } +}`; + + test('finds bash field', () => { + const result = findHookCommandSelection(platformSpecificFormat, 'SessionStart', 0, 'bash'); + assert.ok(result); + assert.strictEqual(getSelectedText(platformSpecificFormat, result), 'echo hello from bash'); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 14, + endLineNumber: 6, + endColumn: 34 + }); + }); + + test('finds powershell field', () => { + const result = findHookCommandSelection(platformSpecificFormat, 'SessionStart', 0, 'powershell'); + assert.ok(result); + assert.strictEqual(getSelectedText(platformSpecificFormat, result), 'Write-Host hello'); + assert.deepStrictEqual(result, { + startLineNumber: 7, + startColumn: 20, + endLineNumber: 7, + endColumn: 36 + }); + }); + }); + + suite('edge cases', () => { + test('returns undefined for empty content', () => { + const result = findHookCommandSelection('', 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for invalid JSON', () => { + const result = findHookCommandSelection('{ invalid json }', 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hooks key is missing', () => { + const content = '{ "version": 1 }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hook type array is empty', () => { + const content = '{ "hooks": { "sessionStart": [] } }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hook item is not an object', () => { + const content = '{ "hooks": { "sessionStart": ["not an object"] } }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('handles empty command string', () => { + const content = `{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "" + } + ] + } +}`; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 17, + endLineNumber: 6, + endColumn: 17 + }); + }); + + test('handles multiline command value', () => { + // JSON strings can contain escaped newlines + const content = `{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "line1\\nline2" + } + ] + } +}`; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'line1\\nline2'); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 17, + endLineNumber: 6, + endColumn: 29 + }); + }); + }); + + suite('nested matcher with empty hooks array', () => { + const emptyNestedHooks = `{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "some-pattern", + "hooks": [] + }, + { + "type": "command", + "command": "after empty nested" + } + ] + } +}`; + + test('skips empty nested hooks and finds subsequent command', () => { + const result = findHookCommandSelection(emptyNestedHooks, 'UserPromptSubmit', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(emptyNestedHooks, result), 'after empty nested'); + assert.deepStrictEqual(result, { + startLineNumber: 10, + startColumn: 17, + endLineNumber: 10, + endColumn: 35 + }); + }); + }); + }); + + suite('findHookCommandSelection - copilotCLICompat', () => { + + suite('simple format', () => { + const simpleFormat = `{ + "version": 1, "hooks": { "sessionStart": [ { @@ -105,7 +418,7 @@ suite('hookUtils', () => { const nestedFormat = `{ "forceLoginMethod": "console", "hooks": { - "UserPromptSubmit": [ + "userPromptSubmitted": [ { "matcher": "", "hooks": [ @@ -120,7 +433,7 @@ suite('hookUtils', () => { }`; test('finds command inside nested hooks', () => { - const result = findHookCommandSelection(nestedFormat, 'UserPromptSubmit', 0, 'command'); + const result = findHookCommandSelection(nestedFormat, 'userPromptSubmitted', 0, 'command'); assert.ok(result); assert.strictEqual(getSelectedText(nestedFormat, result), 'echo \'foobarbaz5\' > ~/foobarbaz.txt'); assert.deepStrictEqual(result, { @@ -132,7 +445,7 @@ suite('hookUtils', () => { }); test('returns undefined for non-existent field name', () => { - const result = findHookCommandSelection(nestedFormat, 'UserPromptSubmit', 0, 'bash'); + const result = findHookCommandSelection(nestedFormat, 'userPromptSubmitted', 0, 'bash'); assert.strictEqual(result, undefined); }); }); @@ -315,7 +628,7 @@ suite('hookUtils', () => { suite('nested matcher with empty hooks array', () => { const emptyNestedHooks = `{ "hooks": { - "UserPromptSubmit": [ + "userPromptSubmitted": [ { "matcher": "some-pattern", "hooks": [] @@ -329,7 +642,7 @@ suite('hookUtils', () => { }`; test('skips empty nested hooks and finds subsequent command', () => { - const result = findHookCommandSelection(emptyNestedHooks, 'UserPromptSubmit', 0, 'command'); + const result = findHookCommandSelection(emptyNestedHooks, 'userPromptSubmitted', 0, 'command'); assert.ok(result); assert.strictEqual(getSelectedText(emptyNestedHooks, result), 'after empty nested'); assert.deepStrictEqual(result, { diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 60e8ff96020c4..407e1b76b6d04 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -24,7 +24,7 @@ import { LanguageModelToolsService } from '../../../browser/tools/languageModelT import { ChatModel, IChatModel } from '../../../common/model/chatModel.js'; import { IChatService, IChatToolInputInvocationData, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { SpecedToolAliases, isToolResultInputOutputDetails, IToolData, IToolImpl, IToolInvocation, ToolDataSource, ToolSet } from '../../../common/tools/languageModelToolsService.js'; +import { SpecedToolAliases, isToolResultInputOutputDetails, IToolData, IToolImpl, IToolInvocation, ToolDataSource, ToolSet, IToolResultTextPart } from '../../../common/tools/languageModelToolsService.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; @@ -32,6 +32,9 @@ import { ILanguageModelToolsConfirmationService } from '../../../common/tools/la import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; +import { IHooksExecutionService, IPreToolUseCallerInput, IPreToolUseHookOutput, IHooksExecutionOptions, IHookResult, IHooksExecutionProxy } from '../../../common/hooksExecutionService.js'; +import { HookTypeValue, IChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; // --- Test helpers to reduce repetition and improve readability --- @@ -59,6 +62,23 @@ class TestTelemetryService implements Partial { } } +class MockHooksExecutionService implements IHooksExecutionService { + readonly _serviceBrand: undefined; + public preToolUseHookResult: IPreToolUseHookOutput | undefined = undefined; + public lastPreToolUseInput: IPreToolUseCallerInput | undefined = undefined; + + setProxy(_proxy: IHooksExecutionProxy): void { } + registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; } + getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { return undefined; } + executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise { + return Promise.resolve([]); + } + async executePreToolUseHook(_sessionResource: URI, input: IPreToolUseCallerInput, _token?: CancellationToken): Promise { + this.lastPreToolUseInput = input; + return this.preToolUseHookResult; + } +} + function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial) { const toolData: IToolData = { id, @@ -99,13 +119,64 @@ function stubGetSession(chatService: MockChatService, sessionId: string, options return fakeModel; } -async function waitForPublishedInvocation(capture: { invocation?: any }, tries = 5): Promise { +async function waitForPublishedInvocation(capture: { invocation?: any }, tries = 10): Promise { for (let i = 0; i < tries && !capture.invocation; i++) { await Promise.resolve(); } return capture.invocation; } +interface TestToolsServiceSetup { + configurationService: TestConfigurationService; + chatService: MockChatService; + service: LanguageModelToolsService; + contextKeyService: IContextKeyService; +} + +interface TestToolsServiceOptions { + accessibilityService?: IAccessibilityService; + accessibilitySignalService?: Partial; + telemetryService?: Partial; + hooksExecutionService?: MockHooksExecutionService; + /** Called after configurationService is created but before the service is instantiated */ + configureServices?: (config: TestConfigurationService) => void; +} + +/** + * Helper to create a LanguageModelToolsService with all common test stubs. + * Reduces boilerplate when tests need custom service configurations. + */ +function createTestToolsService(store: ReturnType, options?: TestToolsServiceOptions): TestToolsServiceSetup { + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); + + // Allow tests to configure before service creation + options?.configureServices?.(configurationService); + + const instaService = workbenchInstantiationService({ + contextKeyService: () => store.add(new ContextKeyService(configurationService)), + configurationService: () => configurationService + }, store); + const contextKeyService = instaService.get(IContextKeyService); + const chatService = new MockChatService(); + instaService.stub(IChatService, chatService); + instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); + instaService.stub(IHooksExecutionService, options?.hooksExecutionService ?? new MockHooksExecutionService()); + + if (options?.accessibilityService) { + instaService.stub(IAccessibilityService, options.accessibilityService); + } + if (options?.accessibilitySignalService) { + instaService.stub(IAccessibilitySignalService, options.accessibilitySignalService as unknown as IAccessibilitySignalService); + } + if (options?.telemetryService) { + instaService.stub(ITelemetryService, options.telemetryService); + } + + const service = store.add(instaService.createInstance(LanguageModelToolsService)); + return { configurationService, chatService, service, contextKeyService }; +} + suite('LanguageModelToolsService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -115,17 +186,11 @@ suite('LanguageModelToolsService', () => { let configurationService: TestConfigurationService; setup(() => { - configurationService = new TestConfigurationService(); - configurationService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(configurationService)), - configurationService: () => configurationService - }, store); - contextKeyService = instaService.get(IContextKeyService); - chatService = new MockChatService(); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - service = store.add(instaService.createInstance(LanguageModelToolsService)); + const setup = createTestToolsService(store); + configurationService = setup.configurationService; + chatService = setup.chatService; + service = setup.service; + contextKeyService = setup.contextKeyService; }); function setupToolsForTest(service: LanguageModelToolsService, store: any) { @@ -1185,11 +1250,6 @@ suite('LanguageModelToolsService', () => { }); test('accessibility signal for tool confirmation', async () => { - // Create a test configuration service with proper settings - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration('chat.tools.global.autoApprove', false); - testConfigService.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'auto', announcement: 'auto' }); - // Create a test accessibility service that simulates screen reader being enabled const testAccessibilityService = new class extends TestAccessibilityService { override isScreenReaderOptimized(): boolean { return true; } @@ -1198,16 +1258,14 @@ suite('LanguageModelToolsService', () => { // Create a test accessibility signal service that tracks calls const testAccessibilitySignalService = new TestAccessibilitySignalService(); - // Create a new service instance with the test services - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(IAccessibilityService, testAccessibilityService); - instaService.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + accessibilityService: testAccessibilityService, + accessibilitySignalService: testAccessibilitySignalService, + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); + config.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'auto', announcement: 'auto' }); + } + }); const toolData: IToolData = { id: 'testAccessibilityTool', @@ -1223,7 +1281,7 @@ suite('LanguageModelToolsService', () => { const sessionId = 'sessionId-accessibility'; const capture: { invocation?: any } = {}; - stubGetSession(chatService, sessionId, { requestId: 'requestId-accessibility', capture }); + stubGetSession(testChatService, sessionId, { requestId: 'requestId-accessibility', capture }); const dto = tool.makeDto({ param: 'value' }, { sessionId }); @@ -1247,11 +1305,6 @@ suite('LanguageModelToolsService', () => { }); test('accessibility signal respects autoApprove configuration', async () => { - // Create a test configuration service with auto-approve enabled - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration('chat.tools.global.autoApprove', true); - testConfigService.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'auto', announcement: 'auto' }); - // Create a test accessibility service that simulates screen reader being enabled const testAccessibilityService = new class extends TestAccessibilityService { override isScreenReaderOptimized(): boolean { return true; } @@ -1260,16 +1313,14 @@ suite('LanguageModelToolsService', () => { // Create a test accessibility signal service that tracks calls const testAccessibilitySignalService = new TestAccessibilitySignalService(); - // Create a new service instance with the test services - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(IAccessibilityService, testAccessibilityService); - instaService.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + accessibilityService: testAccessibilityService, + accessibilitySignalService: testAccessibilitySignalService, + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', true); + config.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'auto', announcement: 'auto' }); + } + }); const toolData: IToolData = { id: 'testAutoApproveTool', @@ -1285,7 +1336,7 @@ suite('LanguageModelToolsService', () => { const sessionId = 'sessionId-auto-approve'; const capture: { invocation?: any } = {}; - stubGetSession(chatService, sessionId, { requestId: 'requestId-auto-approve', capture }); + stubGetSession(testChatService, sessionId, { requestId: 'requestId-auto-approve', capture }); const dto = tool.makeDto({ config: 'test' }, { sessionId }); @@ -1299,16 +1350,11 @@ suite('LanguageModelToolsService', () => { test('shouldAutoConfirm with basic configuration', async () => { // Test basic shouldAutoConfirm behavior with simple configuration - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration('chat.tools.global.autoApprove', true); // Global enabled - - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', true); // Global enabled + } + }); // Register a tool that should be auto-approved const autoTool = registerToolForTest(testService, store, 'autoTool', { @@ -1317,7 +1363,7 @@ suite('LanguageModelToolsService', () => { }); const sessionId = 'test-basic-config'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); // Tool should be auto-approved (global config = true) const result = await testService.invokeTool( @@ -1330,20 +1376,15 @@ suite('LanguageModelToolsService', () => { test('shouldAutoConfirm with per-tool configuration object', async () => { // Test per-tool configuration: { toolId: true/false } - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration('chat.tools.global.autoApprove', { - 'approvedTool': true, - 'deniedTool': false + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', { + 'approvedTool': true, + 'deniedTool': false + }); + } }); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); - // Tool explicitly approved const approvedTool = registerToolForTest(testService, store, 'approvedTool', { prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Test', message: 'Should auto-approve' } }), @@ -1351,7 +1392,7 @@ suite('LanguageModelToolsService', () => { }); const sessionId = 'test-per-tool'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); // Approved tool should auto-approve const approvedResult = await testService.invokeTool( @@ -1368,7 +1409,7 @@ suite('LanguageModelToolsService', () => { }); const capture: { invocation?: any } = {}; - stubGetSession(chatService, sessionId + '2', { requestId: 'req2', capture }); + stubGetSession(testChatService, sessionId + '2', { requestId: 'req2', capture }); const unspecifiedPromise = testService.invokeTool( unspecifiedTool.makeDto({ test: 2 }, { sessionId: sessionId + '2' }), async () => 0, @@ -1384,20 +1425,15 @@ suite('LanguageModelToolsService', () => { test('eligibleForAutoApproval setting controls tool eligibility', async () => { // Test the new eligibleForAutoApproval setting - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { - 'eligibleToolRef': true, - 'ineligibleToolRef': false + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { + 'eligibleToolRef': true, + 'ineligibleToolRef': false + }); + } }); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); - // Tool explicitly marked as eligible (using toolReferenceName) - no confirmation needed const eligibleTool = registerToolForTest(testService, store, 'eligibleTool', { prepareToolInvocation: async () => ({}), @@ -1407,7 +1443,7 @@ suite('LanguageModelToolsService', () => { }); const sessionId = 'test-eligible'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); // Eligible tool should not get default confirmation messages injected const eligibleResult = await testService.invokeTool( @@ -1426,7 +1462,7 @@ suite('LanguageModelToolsService', () => { }); const capture: { invocation?: any } = {}; - stubGetSession(chatService, sessionId + '2', { requestId: 'req2', capture }); + stubGetSession(testChatService, sessionId + '2', { requestId: 'req2', capture }); const ineligiblePromise = testService.invokeTool( ineligibleTool.makeDto({ test: 2 }, { sessionId: sessionId + '2' }), async () => 0, @@ -1512,14 +1548,9 @@ suite('LanguageModelToolsService', () => { test('tool error handling and telemetry', async () => { const testTelemetryService = new TestTelemetryService(); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(configurationService)), - configurationService: () => configurationService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ITelemetryService, testTelemetryService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + telemetryService: testTelemetryService + }); // Test successful invocation telemetry const successTool = registerToolForTest(testService, store, 'successTool', { @@ -1528,7 +1559,7 @@ suite('LanguageModelToolsService', () => { }); const sessionId = 'telemetry-test'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); await testService.invokeTool( successTool.makeDto({ test: 1 }, { sessionId }), @@ -1551,7 +1582,7 @@ suite('LanguageModelToolsService', () => { invoke: async () => { throw new Error('Tool error'); } }); - stubGetSession(chatService, sessionId + '2', { requestId: 'req2' }); + stubGetSession(testChatService, sessionId + '2', { requestId: 'req2' }); try { await testService.invokeTool( @@ -1609,6 +1640,7 @@ suite('LanguageModelToolsService', () => { instaService1.stub(IAccessibilityService, testAccessibilityService1); instaService1.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService1.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); + instaService1.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService1 = store.add(instaService1.createInstance(LanguageModelToolsService)); const tool1 = registerToolForTest(testService1, store, 'soundOnlyTool', { @@ -1650,6 +1682,7 @@ suite('LanguageModelToolsService', () => { instaService2.stub(IAccessibilityService, testAccessibilityService2); instaService2.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService2.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); + instaService2.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService2 = store.add(instaService2.createInstance(LanguageModelToolsService)); const tool2 = registerToolForTest(testService2, store, 'autoScreenReaderTool', { @@ -1692,6 +1725,7 @@ suite('LanguageModelToolsService', () => { instaService3.stub(IAccessibilityService, testAccessibilityService3); instaService3.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService3.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); + instaService3.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService3 = store.add(instaService3.createInstance(LanguageModelToolsService)); const tool3 = registerToolForTest(testService3, store, 'offTool', { @@ -2045,17 +2079,11 @@ suite('LanguageModelToolsService', () => { }); test('shouldAutoConfirm with workspace-specific tool configuration', async () => { - const testConfigService = new TestConfigurationService(); - // Configure per-tool settings at different scopes - testConfigService.setUserConfiguration('chat.tools.global.autoApprove', { 'workspaceTool': true }); - - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', { 'workspaceTool': true }); + } + }); const workspaceTool = registerToolForTest(testService, store, 'workspaceTool', { prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Test', message: 'Workspace tool' } }), @@ -2063,7 +2091,7 @@ suite('LanguageModelToolsService', () => { }, { runsInWorkspace: true }); const sessionId = 'workspace-test'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); // Should auto-approve based on user configuration const result = await testService.invokeTool( @@ -2157,22 +2185,15 @@ suite('LanguageModelToolsService', () => { test('eligibleForAutoApproval setting can be configured via policy', async () => { // Test that policy configuration works for eligibleForAutoApproval // Policy values should be JSON strings for object-type settings - const testConfigService = new TestConfigurationService(); - - // Simulate policy configuration (would come from policy file) - const policyValue = { - 'toolA': true, - 'toolB': false - }; - testConfigService.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, policyValue); - - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + // Simulate policy configuration (would come from policy file) + config.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { + 'toolA': true, + 'toolB': false + }); + } + }); // Tool A is eligible (true in policy) const toolA = registerToolForTest(testService, store, 'toolA', { @@ -2191,7 +2212,7 @@ suite('LanguageModelToolsService', () => { }); const sessionId = 'test-policy'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); // Tool A should execute without confirmation (eligible) const resultA = await testService.invokeTool( @@ -2203,7 +2224,7 @@ suite('LanguageModelToolsService', () => { // Tool B should require confirmation (ineligible) const capture: { invocation?: any } = {}; - stubGetSession(chatService, sessionId + '2', { requestId: 'req2', capture }); + stubGetSession(testChatService, sessionId + '2', { requestId: 'req2', capture }); const promiseB = testService.invokeTool( toolB.makeDto({ test: 2 }, { sessionId: sessionId + '2' }), async () => 0, @@ -2220,19 +2241,14 @@ suite('LanguageModelToolsService', () => { test('eligibleForAutoApproval with legacy tool reference names - eligible', async () => { // Test backwards compatibility: configuring a legacy name as eligible should work - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { - 'oldToolName': true // Using legacy name + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { + 'oldToolName': true // Using legacy name + }); + } }); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); - // Tool has been renamed but has legacy name const renamedTool = registerToolForTest(testService, store, 'renamedTool', { prepareToolInvocation: async () => ({}), @@ -2243,7 +2259,7 @@ suite('LanguageModelToolsService', () => { }); const sessionId = 'test-legacy-eligible'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); // Tool should be eligible even though we configured the legacy name const result = await testService.invokeTool( @@ -2256,19 +2272,14 @@ suite('LanguageModelToolsService', () => { test('eligibleForAutoApproval with legacy tool reference names - ineligible', async () => { // Test backwards compatibility: configuring a legacy name as ineligible should work - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { - 'deprecatedToolName': false // Using legacy name + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { + 'deprecatedToolName': false // Using legacy name + }); + } }); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); - // Tool has been renamed but has legacy name const renamedTool = registerToolForTest(testService, store, 'renamedTool2', { prepareToolInvocation: async () => ({}), @@ -2280,7 +2291,7 @@ suite('LanguageModelToolsService', () => { const sessionId = 'test-legacy-ineligible'; const capture: { invocation?: any } = {}; - stubGetSession(chatService, sessionId, { requestId: 'req1', capture }); + stubGetSession(testChatService, sessionId, { requestId: 'req1', capture }); // Tool should be ineligible and require confirmation const promise = testService.invokeTool( @@ -2299,19 +2310,14 @@ suite('LanguageModelToolsService', () => { test('eligibleForAutoApproval with multiple legacy names', async () => { // Test that any of the legacy names can be used in the configuration - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { - 'secondLegacyName': true // Using the second legacy name + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { + 'secondLegacyName': true // Using the second legacy name + }); + } }); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); - // Tool has multiple legacy names const multiLegacyTool = registerToolForTest(testService, store, 'multiLegacyTool', { prepareToolInvocation: async () => ({}), @@ -2322,7 +2328,7 @@ suite('LanguageModelToolsService', () => { }); const sessionId = 'test-multi-legacy'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); // Tool should be eligible via second legacy name const result = await testService.invokeTool( @@ -2335,20 +2341,15 @@ suite('LanguageModelToolsService', () => { test('eligibleForAutoApproval current name takes precedence over legacy names', async () => { // Test forward compatibility: current name in config should take precedence - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { - 'currentName': false, // Current name says ineligible - 'oldName': true // Legacy name says eligible + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { + 'currentName': false, // Current name says ineligible + 'oldName': true // Legacy name says eligible + }); + } }); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); - const tool = registerToolForTest(testService, store, 'precedenceTool', { prepareToolInvocation: async () => ({}), invoke: async () => ({ content: [{ kind: 'text', value: 'precedence test' }] }) @@ -2359,7 +2360,7 @@ suite('LanguageModelToolsService', () => { const sessionId = 'test-precedence'; const capture: { invocation?: any } = {}; - stubGetSession(chatService, sessionId, { requestId: 'req1', capture }); + stubGetSession(testChatService, sessionId, { requestId: 'req1', capture }); // Current name should take precedence, so tool should be ineligible const promise = testService.invokeTool( @@ -2378,19 +2379,14 @@ suite('LanguageModelToolsService', () => { test('eligibleForAutoApproval with legacy full reference names from toolsets', async () => { // Test legacy names that include toolset prefixes (e.g., 'oldToolSet/oldToolName') - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { - 'oldToolSet/oldToolName': false // Legacy full reference name from old toolset + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { + 'oldToolSet/oldToolName': false // Legacy full reference name from old toolset + }); + } }); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); - // Tool was in an old toolset but now standalone const migratedTool = registerToolForTest(testService, store, 'migratedTool', { prepareToolInvocation: async () => ({}), @@ -2402,7 +2398,7 @@ suite('LanguageModelToolsService', () => { const sessionId = 'test-fullReferenceName-legacy'; const capture: { invocation?: any } = {}; - stubGetSession(chatService, sessionId, { requestId: 'req1', capture }); + stubGetSession(testChatService, sessionId, { requestId: 'req1', capture }); // Tool should be ineligible based on legacy full reference name const promise = testService.invokeTool( @@ -2421,21 +2417,16 @@ suite('LanguageModelToolsService', () => { test('eligibleForAutoApproval mixed current and legacy names', async () => { // Test realistic migration scenario with mixed current and legacy names - const testConfigService = new TestConfigurationService(); - testConfigService.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { - 'modernTool': true, // Current name - 'legacyToolOld': false, // Legacy name - 'unchangedTool': true // Tool that never changed + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration(ChatConfiguration.EligibleForAutoApproval, { + 'modernTool': true, // Current name + 'legacyToolOld': false, // Legacy name + 'unchangedTool': true // Tool that never changed + }); + } }); - const instaService = workbenchInstantiationService({ - contextKeyService: () => store.add(new ContextKeyService(testConfigService)), - configurationService: () => testConfigService - }, store); - instaService.stub(IChatService, chatService); - instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - const testService = store.add(instaService.createInstance(LanguageModelToolsService)); - // Modern tool with current name const tool1 = registerToolForTest(testService, store, 'tool1', { prepareToolInvocation: async () => ({}), @@ -2462,7 +2453,7 @@ suite('LanguageModelToolsService', () => { }); const sessionId = 'test-mixed'; - stubGetSession(chatService, sessionId, { requestId: 'req1' }); + stubGetSession(testChatService, sessionId, { requestId: 'req1' }); // Tool 1 should be eligible (current name) const result1 = await testService.invokeTool( @@ -2474,7 +2465,7 @@ suite('LanguageModelToolsService', () => { // Tool 2 should be ineligible (legacy name) const capture2: { invocation?: any } = {}; - stubGetSession(chatService, sessionId + '2', { requestId: 'req2', capture: capture2 }); + stubGetSession(testChatService, sessionId + '2', { requestId: 'req2', capture: capture2 }); const promise2 = testService.invokeTool( tool2.makeDto({ test: 2 }, { sessionId: sessionId + '2' }), async () => 0, @@ -2508,6 +2499,7 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); + instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); const tool = registerToolForTest(testService, store, 'gitCommitTool', { @@ -2546,6 +2538,7 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); + instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2585,6 +2578,7 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); + instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2627,6 +2621,7 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); + instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -3720,4 +3715,145 @@ suite('LanguageModelToolsService', () => { assert.ok(!toolIds.includes('childToolWithWhen'), 'Child tool with when=true should NOT be in tool set when context key is false'); }); }); + + suite('preToolUse hooks', () => { + let mockHooksService: MockHooksExecutionService; + let hookService: LanguageModelToolsService; + let hookChatService: MockChatService; + + setup(() => { + mockHooksService = new MockHooksExecutionService(); + const setup = createTestToolsService(store, { + hooksExecutionService: mockHooksService + }); + hookService = setup.service; + hookChatService = setup.chatService; + }); + + test('when hook denies, tool returns error and creates cancelled invocation', async () => { + mockHooksService.preToolUseHookResult = { + permissionDecision: 'deny', + permissionDecisionReason: 'Destructive operations require approval', + }; + + const tool = registerToolForTest(hookService, store, 'hookDenyTool', { + invoke: async () => ({ content: [{ kind: 'text', value: 'should not run' }] }) + }); + + const capture: { invocation?: ChatToolInvocation } = {}; + stubGetSession(hookChatService, 'hook-test', { requestId: 'req1', capture }); + + const result = await hookService.invokeTool( + tool.makeDto({ test: 1 }, { sessionId: 'hook-test' }), + async () => 0, + CancellationToken.None + ); + + // Verify error result returned + assert.ok(result.toolResultError); + assert.ok(result.toolResultError.includes('Destructive operations require approval')); + assert.strictEqual(result.content[0].kind, 'text'); + assert.ok((result.content[0] as IToolResultTextPart).value.includes('Tool execution denied')); + + // Verify a cancelled invocation was created + const invocation = await waitForPublishedInvocation(capture); + assert.ok(invocation); + const state = invocation.state.get(); + assert.strictEqual(state.type, IChatToolInvocation.StateKind.Cancelled); + if (state.type === IChatToolInvocation.StateKind.Cancelled) { + assert.strictEqual(state.reason, ToolConfirmKind.Denied); + assert.strictEqual(state.reasonMessage, 'Denied by preToolUse hook: Destructive operations require approval'); + } + }); + + test('when hook allows, tool executes normally', async () => { + mockHooksService.preToolUseHookResult = { + permissionDecision: 'allow', + }; + + const tool = registerToolForTest(hookService, store, 'hookAllowTool', { + invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) + }); + + const capture: { invocation?: ChatToolInvocation } = {}; + stubGetSession(hookChatService, 'hook-test-allow', { requestId: 'req1', capture }); + + const result = await hookService.invokeTool( + tool.makeDto({ test: 1 }, { sessionId: 'hook-test-allow' }), + async () => 0, + CancellationToken.None + ); + + assert.strictEqual(result.content[0].kind, 'text'); + assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'success'); + assert.ok(!result.toolResultError); + }); + + test('when hook returns undefined, tool executes normally', async () => { + mockHooksService.preToolUseHookResult = undefined; + + const tool = registerToolForTest(hookService, store, 'hookUndefinedTool', { + invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) + }); + + stubGetSession(hookChatService, 'hook-test-undefined', { requestId: 'req1' }); + + const result = await hookService.invokeTool( + tool.makeDto({ test: 1 }, { sessionId: 'hook-test-undefined' }), + async () => 0, + CancellationToken.None + ); + + assert.strictEqual(result.content[0].kind, 'text'); + assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'success'); + }); + + test('hook receives correct input parameters', async () => { + mockHooksService.preToolUseHookResult = { + permissionDecision: 'allow', + }; + + const tool = registerToolForTest(hookService, store, 'hookInputTool', { + invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) + }); + + stubGetSession(hookChatService, 'hook-test-input', { requestId: 'req1' }); + + await hookService.invokeTool( + tool.makeDto({ param1: 'value1', param2: 42 }, { sessionId: 'hook-test-input' }), + async () => 0, + CancellationToken.None + ); + + assert.ok(mockHooksService.lastPreToolUseInput); + assert.strictEqual(mockHooksService.lastPreToolUseInput.toolName, 'hookInputTool'); + assert.deepStrictEqual(mockHooksService.lastPreToolUseInput.toolArgs, { param1: 'value1', param2: 42 }); + }); + + test('when hook denies, tool invoke is never called', async () => { + mockHooksService.preToolUseHookResult = { + permissionDecision: 'deny', + permissionDecisionReason: 'Operation not allowed', + }; + + let invokeCalled = false; + const tool = registerToolForTest(hookService, store, 'hookNeverInvokeTool', { + invoke: async () => { + invokeCalled = true; + return { content: [{ kind: 'text', value: 'should not run' }] }; + } + }); + + const capture: { invocation?: unknown } = {}; + stubGetSession(hookChatService, 'hook-test-no-invoke', { requestId: 'req1', capture }); + + await hookService.invokeTool( + tool.makeDto({ test: 1 }, { sessionId: 'hook-test-no-invoke' }), + async () => 0, + CancellationToken.None + ); + + assert.strictEqual(invokeCalled, false, 'Tool invoke should not be called when hook denies'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTodoListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTodoListWidget.test.ts index 3d35a1b0bf5c5..614dfd24213e9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTodoListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatTodoListWidget.test.ts @@ -4,15 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { isAncestorOfActiveElement } from '../../../../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; import { Event } from '../../../../../../../base/common/event.js'; +import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; -import { ChatTodoListWidget } from '../../../../browser/widget/chatContentParts/chatTodoListWidget.js'; -import { IChatTodo, IChatTodoListService } from '../../../../common/tools/chatTodoListService.js'; -import { mainWindow } from '../../../../../../../base/browser/window.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; -import { URI } from '../../../../../../../base/common/uri.js'; +import { ChatTodoListWidget } from '../../../../browser/widget/chatContentParts/chatTodoListWidget.js'; +import { IChatTodo, IChatTodoListService } from '../../../../common/tools/chatTodoListService.js'; const testSessionUri = URI.parse('chat-session://test/session1'); @@ -199,4 +200,45 @@ suite('ChatTodoListWidget Accessibility', () => { const todoListContainer = widget.domNode.querySelector('.todo-list-container'); assert.strictEqual(todoListContainer?.getAttribute('aria-labelledby'), 'todo-list-title'); }); + + test('focus expands and places focus on the todo list', () => { + widget.render(testSessionUri); + + const expandoButton = widget.domNode.querySelector('.todo-list-expand .monaco-button'); + assert.strictEqual(expandoButton?.getAttribute('aria-expanded'), 'false', 'Todo list should start collapsed'); + + const focused = widget.focus(); + assert.strictEqual(focused, true, 'Focus should succeed when todos are present'); + assert.strictEqual(expandoButton?.getAttribute('aria-expanded'), 'true', 'Focus should expand the todo list'); + + const todoListContainer = widget.domNode.querySelector('.todo-list-container') as HTMLElement; + assert.ok(todoListContainer, 'Todo list container should exist'); + assert.ok(isAncestorOfActiveElement(todoListContainer), 'Todo list container should contain the active element after focusing'); + }); + + test('hasTodos reports visibility state', () => { + widget.render(testSessionUri); + assert.strictEqual(widget.hasTodos(), true, 'Widget should report todos are present'); + + const emptyTodoListService: IChatTodoListService = { + _serviceBrand: undefined, + onDidUpdateTodos: Event.None, + getTodos: () => [], + setTodos: () => { }, + migrateTodos: () => { } + }; + const emptyConfigurationService = new TestConfigurationService({ 'chat.todoListTool.descriptionField': true }); + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IChatTodoListService, emptyTodoListService); + instantiationService.stub(IConfigurationService, emptyConfigurationService); + const emptyWidget = store.add(instantiationService.createInstance(ChatTodoListWidget)); + mainWindow.document.body.appendChild(emptyWidget.domNode); + + emptyWidget.render(testSessionUri); + assert.strictEqual(emptyWidget.hasTodos(), false, 'Widget should report no todos when the list is empty'); + + if (emptyWidget.domNode.parentNode) { + emptyWidget.domNode.parentNode.removeChild(emptyWidget.domNode); + } + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts index 35e0dc6bc92d0..09238ec7ae6f0 100644 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts @@ -203,7 +203,14 @@ suite('HooksExecutionService', () => { const testInput = { foo: 'bar', nested: { value: 123 } }; await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput }); - assert.deepStrictEqual(receivedInput, testInput); + // Input includes caller properties merged with common hook properties + assert.ok(typeof receivedInput === 'object' && receivedInput !== null); + const input = receivedInput as Record; + assert.strictEqual(input['foo'], 'bar'); + assert.deepStrictEqual(input['nested'], { value: 123 }); + // Common properties are also present + assert.strictEqual(typeof input['timestamp'], 'string'); + assert.strictEqual(input['hookEventName'], HookType.PreToolUse); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index fafc1500aa2cc..79195b6d4ff3c 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -17,7 +17,6 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { FileService } from '../../../../../../platform/files/common/fileService.js'; -import { InMemoryFileSystemProvider } from '../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -34,7 +33,7 @@ import { INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_ import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../common/promptSyntax/service/promptsServiceImpl.js'; -import { mockFiles } from './testUtils/mockFilesystem.js'; +import { mockFiles, TestInMemoryFileSystemProviderWithRealPath } from './testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; import { IFileQuery, ISearchService } from '../../../../../services/search/common/search.js'; @@ -53,6 +52,7 @@ suite('ComputeAutomaticInstructions', () => { let testConfigService: TestConfigurationService; let fileService: IFileService; let toolsService: ILanguageModelToolsService; + let fileSystemProvider: TestInMemoryFileSystemProviderWithRealPath; setup(async () => { instaService = disposables.add(new TestInstantiationService()); @@ -64,6 +64,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); testConfigService.setUserConfiguration(PromptsConfig.USE_NESTED_AGENT_MD, false); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); @@ -109,7 +110,7 @@ suite('ComputeAutomaticInstructions', () => { } }); - const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); + fileSystemProvider = disposables.add(new TestInMemoryFileSystemProviderWithRealPath()); disposables.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); const pathService = { @@ -173,6 +174,7 @@ suite('ComputeAutomaticInstructions', () => { teardown(() => { sinon.restore(); + fileSystemProvider.clearRealPathMappings(); }); suite('collect', () => { @@ -1178,4 +1180,508 @@ suite('ComputeAutomaticInstructions', () => { assert.ok(true, 'Should handle cancellation without errors'); }); }); + + test('should collect CLAUDE.md when enabled', async () => { + const rootFolderName = 'collect-claude-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/CLAUDE.md`, + contents: [ + 'Claude guidelines', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: [ + 'console.log("test");', + ] + }, + ]); + + // Test when USE_CLAUDE_MD is true + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + let instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + let paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + assert.ok(paths.includes(`${rootFolder}/CLAUDE.md`), 'Should include CLAUDE.md when enabled'); + + // Test when USE_CLAUDE_MD is false + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables2 = new ChatRequestVariableSet(); + variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer2.collect(variables2, CancellationToken.None); + + instructionFiles = variables2.asArray().filter(v => isPromptFileVariableEntry(v)); + paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + assert.ok(!paths.includes(`${rootFolder}/CLAUDE.md`), 'Should not include CLAUDE.md when disabled'); + }); + + test('should collect .claude/CLAUDE.md when enabled', async () => { + const rootFolderName = 'collect-claude-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/CLAUDE.md`, + contents: [ + 'Claude guidelines', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: [ + 'console.log("test");', + ] + }, + ]); + + // Test when USE_CLAUDE_MD is true + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + let instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + let paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + assert.ok(paths.includes(`${rootFolder}/.claude/CLAUDE.md`), 'Should include .claude/CLAUDE.md when enabled'); + + // Test when USE_CLAUDE_MD is false + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables2 = new ChatRequestVariableSet(); + variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer2.collect(variables2, CancellationToken.None); + + instructionFiles = variables2.asArray().filter(v => isPromptFileVariableEntry(v)); + paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + assert.ok(!paths.includes(`${rootFolder}/.claude/CLAUDE.md`), 'Should not include .claude/CLAUDE.md when disabled'); + }); + + test('should collect ~/.claude/CLAUDE.md when enabled', async () => { + const rootFolderName = 'collect-claude-home-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `/home/user/.claude/CLAUDE.md`, + contents: [ + 'Claude guidelines from home', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: [ + 'console.log("test");', + ] + }, + ]); + + // Test when USE_CLAUDE_MD is true + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + let instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + let paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + assert.ok(paths.includes(`/home/user/.claude/CLAUDE.md`), 'Should include ~/.claude/CLAUDE.md when enabled'); + + // Test when USE_CLAUDE_MD is false + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables2 = new ChatRequestVariableSet(); + variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer2.collect(variables2, CancellationToken.None); + + instructionFiles = variables2.asArray().filter(v => isPromptFileVariableEntry(v)); + paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + assert.ok(!paths.includes(`/home/user/.claude/CLAUDE.md`), 'Should not include ~/.claude/CLAUDE.md when disabled'); + }); + + test('should collect instructions from multi-root workspace', async () => { + const rootFolder1Name = 'multi-root-1'; + const rootFolder1 = `/${rootFolder1Name}`; + const rootFolder1Uri = URI.file(rootFolder1); + + const rootFolder2Name = 'multi-root-2'; + const rootFolder2 = `/${rootFolder2Name}`; + const rootFolder2Uri = URI.file(rootFolder2); + + workspaceContextService.setWorkspace(testWorkspace(rootFolder1Uri, rootFolder2Uri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder1}/.github/instructions/ts.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.ts"', + '---', + 'TS from root 1', + ] + }, + { + path: `${rootFolder2}/.github/instructions/js.instructions.md`, + contents: [ + '---', + 'applyTo: "**/*.js"', + '---', + 'JS from root 2', + ] + }, + { + path: `${rootFolder1}/src/file.ts`, + contents: ['console.log("test");'], + }, + { + path: `${rootFolder2}/src/file.js`, + contents: ['console.log("test");'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.strictEqual(instructionFiles.length, 2, 'Should collect one instruction from each root'); + assert.ok(paths.includes(`${rootFolder1}/.github/instructions/ts.instructions.md`), 'Should include instruction from first root'); + assert.ok(paths.includes(`${rootFolder2}/.github/instructions/js.instructions.md`), 'Should include instruction from second root'); + }); + + test('should collect CLAUDE.md from multi-root workspace', async () => { + const rootFolder1Name = 'multi-root-claude-1'; + const rootFolder1 = `/${rootFolder1Name}`; + const rootFolder1Uri = URI.file(rootFolder1); + + const rootFolder2Name = 'multi-root-claude-2'; + const rootFolder2 = `/${rootFolder2Name}`; + const rootFolder2Uri = URI.file(rootFolder2); + + workspaceContextService.setWorkspace(testWorkspace(rootFolder1Uri, rootFolder2Uri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder1}/CLAUDE.md`, + contents: ['Claude guidelines from root 1'], + }, + { + path: `${rootFolder2}/CLAUDE.md`, + contents: ['Claude guidelines from root 2'], + }, + { + path: `${rootFolder1}/src/file.ts`, + contents: ['console.log("test");'], + }, + { + path: `${rootFolder2}/src/file.js`, + contents: ['console.log("test");'], + }, + ]); + + // Test when USE_CLAUDE_MD is true + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder1}/CLAUDE.md`), 'Should include CLAUDE.md from first root'); + assert.ok(paths.includes(`${rootFolder2}/CLAUDE.md`), 'Should include CLAUDE.md from second root'); + }); + + test('should collect .claude/CLAUDE.md from multi-root workspace', async () => { + const rootFolder1Name = 'multi-root-dotclaude-1'; + const rootFolder1 = `/${rootFolder1Name}`; + const rootFolder1Uri = URI.file(rootFolder1); + + const rootFolder2Name = 'multi-root-dotclaude-2'; + const rootFolder2 = `/${rootFolder2Name}`; + const rootFolder2Uri = URI.file(rootFolder2); + + workspaceContextService.setWorkspace(testWorkspace(rootFolder1Uri, rootFolder2Uri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder1}/.claude/CLAUDE.md`, + contents: ['Claude guidelines from .claude folder in root 1'], + }, + { + path: `${rootFolder2}/.claude/CLAUDE.md`, + contents: ['Claude guidelines from .claude folder in root 2'], + }, + { + path: `${rootFolder1}/src/file.ts`, + contents: ['console.log("test");'], + }, + { + path: `${rootFolder2}/src/file.js`, + contents: ['console.log("test");'], + }, + ]); + + // Test when USE_CLAUDE_MD is true + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder1}/.claude/CLAUDE.md`), 'Should include .claude/CLAUDE.md from first root'); + assert.ok(paths.includes(`${rootFolder2}/.claude/CLAUDE.md`), 'Should include .claude/CLAUDE.md from second root'); + }); + + test('should collect both root CLAUDE.md and .claude/CLAUDE.md from multi-root workspace', async () => { + const rootFolder1Name = 'multi-root-mixed-1'; + const rootFolder1 = `/${rootFolder1Name}`; + const rootFolder1Uri = URI.file(rootFolder1); + + const rootFolder2Name = 'multi-root-mixed-2'; + const rootFolder2 = `/${rootFolder2Name}`; + const rootFolder2Uri = URI.file(rootFolder2); + + workspaceContextService.setWorkspace(testWorkspace(rootFolder1Uri, rootFolder2Uri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder1}/CLAUDE.md`, + contents: ['Claude guidelines from root 1'], + }, + { + path: `${rootFolder1}/.claude/CLAUDE.md`, + contents: ['Claude guidelines from .claude folder in root 1'], + }, + { + path: `${rootFolder2}/CLAUDE.md`, + contents: ['Claude guidelines from root 2'], + }, + { + path: `${rootFolder2}/.claude/CLAUDE.md`, + contents: ['Claude guidelines from .claude folder in root 2'], + }, + { + path: `${rootFolder1}/src/file.ts`, + contents: ['console.log("test");'], + }, + { + path: `${rootFolder2}/src/file.js`, + contents: ['console.log("test");'], + }, + ]); + + // Test when USE_CLAUDE_MD is true + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder1}/CLAUDE.md`), 'Should include CLAUDE.md from first root'); + assert.ok(paths.includes(`${rootFolder1}/.claude/CLAUDE.md`), 'Should include .claude/CLAUDE.md from first root'); + assert.ok(paths.includes(`${rootFolder2}/CLAUDE.md`), 'Should include CLAUDE.md from second root'); + assert.ok(paths.includes(`${rootFolder2}/.claude/CLAUDE.md`), 'Should include .claude/CLAUDE.md from second root'); + }); + + test('should not collect CLAUDE.md from multi-root workspace when disabled', async () => { + const rootFolder1Name = 'multi-root-disabled-1'; + const rootFolder1 = `/${rootFolder1Name}`; + const rootFolder1Uri = URI.file(rootFolder1); + + const rootFolder2Name = 'multi-root-disabled-2'; + const rootFolder2 = `/${rootFolder2Name}`; + const rootFolder2Uri = URI.file(rootFolder2); + + workspaceContextService.setWorkspace(testWorkspace(rootFolder1Uri, rootFolder2Uri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder1}/CLAUDE.md`, + contents: ['Claude guidelines from root 1'], + }, + { + path: `${rootFolder2}/CLAUDE.md`, + contents: ['Claude guidelines from root 2'], + }, + { + path: `${rootFolder1}/src/file.ts`, + contents: ['console.log("test");'], + }, + { + path: `${rootFolder2}/src/file.js`, + contents: ['console.log("test");'], + }, + ]); + + // Test when USE_CLAUDE_MD is false + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(!paths.includes(`${rootFolder1}/CLAUDE.md`), 'Should not include CLAUDE.md from first root when disabled'); + assert.ok(!paths.includes(`${rootFolder2}/CLAUDE.md`), 'Should not include CLAUDE.md from second root when disabled'); + }); + + test('should collect both CLAUDE.md and CLAUDE.local.md from multi-root workspace', async () => { + const rootFolder1Name = 'multi-root-claude-both-1'; + const rootFolder1 = `/${rootFolder1Name}`; + const rootFolder1Uri = URI.file(rootFolder1); + + const rootFolder2Name = 'multi-root-claude-both-2'; + const rootFolder2 = `/${rootFolder2Name}`; + const rootFolder2Uri = URI.file(rootFolder2); + + workspaceContextService.setWorkspace(testWorkspace(rootFolder1Uri, rootFolder2Uri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder1}/CLAUDE.md`, + contents: ['Claude guidelines from root 1'], + }, + { + path: `${rootFolder1}/CLAUDE.local.md`, + contents: ['Local Claude guidelines from root 1'], + }, + { + path: `${rootFolder2}/CLAUDE.md`, + contents: ['Claude guidelines from root 2'], + }, + { + path: `${rootFolder2}/CLAUDE.local.md`, + contents: ['Local Claude guidelines from root 2'], + }, + { + path: `${rootFolder1}/src/file.ts`, + contents: ['console.log("test");'], + }, + { + path: `${rootFolder2}/src/file.js`, + contents: ['console.log("test");'], + }, + ]); + + // Test when USE_CLAUDE_MD is true + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); + variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder1}/CLAUDE.md`), 'Should include CLAUDE.md from first root'); + assert.ok(paths.includes(`${rootFolder1}/CLAUDE.local.md`), 'Should include CLAUDE.local.md from first root'); + assert.ok(paths.includes(`${rootFolder2}/CLAUDE.md`), 'Should include CLAUDE.md from second root'); + assert.ok(paths.includes(`${rootFolder2}/CLAUDE.local.md`), 'Should include CLAUDE.local.md from second root'); + }); + + test('should filter symlinks', async () => { + const rootFolderName = 'partial-symlink-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const copilotUri = URI.joinPath(rootFolderUri, '.github/copilot-instructions.md'); + const agentMdUri = URI.joinPath(rootFolderUri, 'AGENTS.md'); + const claudeMdUri = URI.joinPath(rootFolderUri, 'CLAUDE.md'); + + // Create all three agent instruction files + await mockFiles(fileService, [ + { + path: `${rootFolder}/src/file.ts`, + contents: ['console.log("test");'], + }, + { + path: copilotUri.path, + contents: ['# Copilot Instructions'], + }, + { + path: agentMdUri.path, + contents: ['# Copilot Instructions'], + }, + { + path: claudeMdUri.path, + contents: ['# Copilot Instructions'], + }, + ]); + + // AGENTS.md and CLAUDE.md are symlinks to copilot + fileSystemProvider.setRealPath(agentMdUri, copilotUri); + fileSystemProvider.setRealPath(claudeMdUri, copilotUri); + + // Enable all three types of agent instructions + testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const instructionFiles = variables.asArray().filter(v => isPromptFileVariableEntry(v)); + const paths = instructionFiles.map(i => isPromptFileVariableEntry(i) ? i.value.path : undefined); + + // copilot-instructions.md should be included + // AGENTS.md should be skipped as link to copilot + // CLAUDE.md should be skipped as link to copilot + assert.strictEqual(instructionFiles.length, 1, 'Should include 1 files (copilot)'); + assert.ok(paths.includes(copilotUri.path), 'Should include copilot-instructions.md'); + assert.ok(!paths.includes(agentMdUri.path), 'Should not include AGENTS.md (symlink to copilot)'); + assert.ok(!paths.includes(claudeMdUri.path), 'Should not include CLAUDE.md (symlink to copilot)'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 778b57732ffea..54a2760c28eb5 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -18,7 +18,7 @@ suite('HookClaudeCompat', () => { }); test('resolves UserPromptSubmit', () => { - assert.strictEqual(resolveClaudeHookType('UserPromptSubmit'), HookType.UserPromptSubmitted); + assert.strictEqual(resolveClaudeHookType('UserPromptSubmit'), HookType.UserPromptSubmit); }); test('returns undefined for unknown type', () => { @@ -35,12 +35,8 @@ suite('HookClaudeCompat', () => { assert.strictEqual(getClaudeHookTypeName(HookType.PreToolUse), 'PreToolUse'); }); - test('gets UserPromptSubmit for HookType.UserPromptSubmitted', () => { - assert.strictEqual(getClaudeHookTypeName(HookType.UserPromptSubmitted), 'UserPromptSubmit'); - }); - - test('returns undefined for HookType.PostToolUseFailure (not supported)', () => { - assert.strictEqual(getClaudeHookTypeName(HookType.PostToolUseFailure), undefined); + test('gets UserPromptSubmit for HookType.UserPromptSubmit', () => { + assert.strictEqual(getClaudeHookTypeName(HookType.UserPromptSubmit), 'UserPromptSubmit'); }); }); @@ -209,21 +205,6 @@ suite('HookClaudeCompat', () => { }); }); - suite('hook type name mapping', () => { - test('maps UserPromptSubmit to UserPromptSubmitted', () => { - const json = { - hooks: { - UserPromptSubmit: [{ type: 'command', command: 'echo "submitted"' }] - } - }; - - const result = parseClaudeHooks(json, workspaceRoot, userHome); - - assert.ok(result.has(HookType.UserPromptSubmitted)); - assert.strictEqual(result.get(HookType.UserPromptSubmitted)!.originalId, 'UserPromptSubmit'); - }); - }); - suite('invalid inputs', () => { test('returns empty map for null json', () => { const result = parseClaudeHooks(null, workspaceRoot, userHome); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts index f9557563e11e5..5e11ce1080901 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -5,66 +5,12 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { HookType, normalizeHookTypeId, resolveHookCommand } from '../../../common/promptSyntax/hookSchema.js'; +import { resolveHookCommand } from '../../../common/promptSyntax/hookSchema.js'; import { URI } from '../../../../../../base/common/uri.js'; suite('HookSchema', () => { ensureNoDisposablesAreLeakedInTestSuite(); - suite('normalizeHookTypeId', () => { - - suite('Claude Code hook types (PascalCase)', () => { - // @see https://code.claude.com/docs/en/hooks#hook-lifecycle - - test('SessionStart -> sessionStart', () => { - assert.strictEqual(normalizeHookTypeId('SessionStart'), HookType.SessionStart); - }); - - test('UserPromptSubmit -> userPromptSubmitted', () => { - assert.strictEqual(normalizeHookTypeId('UserPromptSubmit'), HookType.UserPromptSubmitted); - }); - - test('PreToolUse -> preToolUse', () => { - assert.strictEqual(normalizeHookTypeId('PreToolUse'), HookType.PreToolUse); - }); - - test('PostToolUse -> postToolUse', () => { - assert.strictEqual(normalizeHookTypeId('PostToolUse'), HookType.PostToolUse); - }); - - test('PostToolUseFailure -> postToolUseFailure', () => { - assert.strictEqual(normalizeHookTypeId('PostToolUseFailure'), HookType.PostToolUseFailure); - }); - - test('SubagentStart -> subagentStart', () => { - assert.strictEqual(normalizeHookTypeId('SubagentStart'), HookType.SubagentStart); - }); - - test('SubagentStop -> subagentStop', () => { - assert.strictEqual(normalizeHookTypeId('SubagentStop'), HookType.SubagentStop); - }); - - test('Stop -> stop', () => { - assert.strictEqual(normalizeHookTypeId('Stop'), HookType.Stop); - }); - }); - - suite('unknown hook types', () => { - test('unknown type returns undefined', () => { - assert.strictEqual(normalizeHookTypeId('unknownHook'), undefined); - }); - - test('empty string returns undefined', () => { - assert.strictEqual(normalizeHookTypeId(''), undefined); - }); - - test('typo returns undefined', () => { - assert.strictEqual(normalizeHookTypeId('sessionstart'), undefined); - assert.strictEqual(normalizeHookTypeId('SESSIONSTART'), undefined); - }); - }); - }); - suite('resolveHookCommand', () => { const workspaceRoot = URI.file('/workspace'); const userHome = '/home/user'; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index d307385e6a808..ab269f0e48597 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -11,7 +11,7 @@ import { ITextModel } from '../../../../../../../editor/common/model.js'; import { IExtensionDescription } from '../../../../../../../platform/extensions/common/extensions.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { ParsedPromptFile } from '../../../../common/promptSyntax/promptFileParser.js'; -import { IAgentSkill, ICustomAgent, IPromptFileContext, IPromptFileResource, IPromptPath, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IAgentSkill, ICustomAgent, IPromptFileContext, IPromptFileResource, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; export class MockPromptsService implements IPromptsService { @@ -56,9 +56,8 @@ export class MockPromptsService implements IPromptsService { getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { throw new Error('Not implemented'); } registerContributedFile(type: PromptsType, uri: URI, extension: IExtensionDescription, name: string | undefined, description: string | undefined): IDisposable { throw new Error('Not implemented'); } getPromptLocationLabel(promptPath: IPromptPath): string { throw new Error('Not implemented'); } - findAgentMDsInWorkspace(token: CancellationToken): Promise { throw new Error('Not implemented'); } - listAgentMDs(token: CancellationToken): Promise { throw new Error('Not implemented'); } - listCopilotInstructionsMDs(token: CancellationToken): Promise { throw new Error('Not implemented'); } + listNestedAgentMDs(token: CancellationToken): Promise { throw new Error('Not implemented'); } + listAgentInstructions(token: CancellationToken): Promise { throw new Error('Not implemented'); } getAgentFileURIFromModeFile(oldURI: URI): URI | undefined { throw new Error('Not implemented'); } getDisabledPromptFiles(type: PromptsType): ResourceSet { throw new Error('Method not implemented.'); } setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void { throw new Error('Method not implemented.'); } 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 306efc1e4da2d..1727898d2468a 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 @@ -2528,4 +2528,458 @@ suite('PromptsService', () => { assert.strictEqual(resultAfterDispose?.[0].name, 'Local Skill'); }); }); + + suite('getPromptSlashCommands - skills', () => { + teardown(() => { + sinon.restore(); + }); + + test('should include skills from workspace as slash commands', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-workspace-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create skill files in workspace + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/workspace-skill/SKILL.md`, + contents: [ + '---', + 'name: "workspace-skill"', + 'description: "A workspace skill that should appear as slash command"', + '---', + 'Workspace skill content', + ], + }, + { + path: `${rootFolder}/.claude/skills/another-skill/SKILL.md`, + contents: [ + '---', + 'name: "another-skill"', + 'description: "Another skill from workspace"', + '---', + 'Another skill content', + ], + }, + ]); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + const workspaceSkillCommand = slashCommands.find(cmd => cmd.name === 'workspace-skill'); + assert.ok(workspaceSkillCommand, 'Should find workspace skill as slash command'); + assert.strictEqual(workspaceSkillCommand.description, 'A workspace skill that should appear as slash command'); + assert.strictEqual(workspaceSkillCommand.promptPath.storage, PromptsStorage.local); + assert.strictEqual(workspaceSkillCommand.promptPath.type, PromptsType.skill); + + const anotherSkillCommand = slashCommands.find(cmd => cmd.name === 'another-skill'); + assert.ok(anotherSkillCommand, 'Should find another skill as slash command'); + assert.strictEqual(anotherSkillCommand.description, 'Another skill from workspace'); + assert.strictEqual(anotherSkillCommand.promptPath.storage, PromptsStorage.local); + }); + + test('should include skills from user storage as slash commands', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-user-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create skill files in user storage (personal skills) + await mockFiles(fileService, [ + { + path: '/home/user/.copilot/skills/personal-skill/SKILL.md', + contents: [ + '---', + 'name: "personal-skill"', + 'description: "A personal skill from user storage"', + '---', + 'Personal skill content', + ], + }, + { + path: '/home/user/.claude/skills/claude-personal/SKILL.md', + contents: [ + '---', + 'name: "claude-personal"', + 'description: "A Claude personal skill"', + '---', + 'Claude personal skill content', + ], + }, + ]); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + const personalSkillCommand = slashCommands.find(cmd => cmd.name === 'personal-skill'); + assert.ok(personalSkillCommand, 'Should find personal skill as slash command'); + assert.strictEqual(personalSkillCommand.description, 'A personal skill from user storage'); + assert.strictEqual(personalSkillCommand.promptPath.storage, PromptsStorage.user); + assert.strictEqual(personalSkillCommand.promptPath.type, PromptsType.skill); + + const claudePersonalCommand = slashCommands.find(cmd => cmd.name === 'claude-personal'); + assert.ok(claudePersonalCommand, 'Should find Claude personal skill as slash command'); + assert.strictEqual(claudePersonalCommand.description, 'A Claude personal skill'); + assert.strictEqual(claudePersonalCommand.promptPath.storage, PromptsStorage.user); + }); + + test('should include skills from extension providers as slash commands', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-provider-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const providerSkillUri = URI.parse('file://extensions/my-extension/provider-skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the skill file content + await mockFiles(fileService, [ + { + path: providerSkillUri.path, + contents: [ + '---', + 'name: "provider-skill"', + 'description: "A skill from extension provider"', + '---', + 'Provider skill content', + ], + }, + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [{ uri: providerSkillUri }]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.skill, provider); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + const providerSkillCommand = slashCommands.find(cmd => cmd.name === 'provider-skill'); + assert.ok(providerSkillCommand, 'Should find provider skill as slash command'); + assert.strictEqual(providerSkillCommand.description, 'A skill from extension provider'); + assert.strictEqual(providerSkillCommand.promptPath.storage, PromptsStorage.extension); + assert.strictEqual(providerSkillCommand.promptPath.type, PromptsType.skill); + assert.strictEqual(providerSkillCommand.promptPath.source, ExtensionAgentSourceType.provider); + + registered.dispose(); + + // After disposal, the provider skill should no longer appear + const slashCommandsAfterDispose = await service.getPromptSlashCommands(CancellationToken.None); + const foundAfterDispose = slashCommandsAfterDispose.find(cmd => cmd.name === 'provider-skill'); + assert.strictEqual(foundAfterDispose, undefined, 'Should not find provider skill after disposal'); + }); + + test('should include skills from extension contributions as slash commands', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-contributed-skills'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const contributedSkillUri = URI.parse('file://extensions/my-extension/contributed-skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' } + } as unknown as IExtensionDescription; + + // Mock the skill file content + await mockFiles(fileService, [ + { + path: contributedSkillUri.path, + contents: [ + '---', + 'name: "contributed-skill"', + 'description: "A skill from extension contribution"', + '---', + 'Contributed skill content', + ], + }, + ]); + + const registered = service.registerContributedFile( + PromptsType.skill, + contributedSkillUri, + extension, + 'contributed-skill', + 'A skill from extension contribution' + ); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + const contributedSkillCommand = slashCommands.find(cmd => cmd.name === 'contributed-skill'); + assert.ok(contributedSkillCommand, 'Should find contributed skill as slash command'); + assert.strictEqual(contributedSkillCommand.description, 'A skill from extension contribution'); + assert.strictEqual(contributedSkillCommand.promptPath.storage, PromptsStorage.extension); + assert.strictEqual(contributedSkillCommand.promptPath.type, PromptsType.skill); + assert.strictEqual(contributedSkillCommand.promptPath.source, ExtensionAgentSourceType.contribution); + + registered.dispose(); + + // After disposal, the contributed skill should no longer appear + const slashCommandsAfterDispose = await service.getPromptSlashCommands(CancellationToken.None); + const foundAfterDispose = slashCommandsAfterDispose.find(cmd => cmd.name === 'contributed-skill'); + assert.strictEqual(foundAfterDispose, undefined, 'Should not find contributed skill after disposal'); + }); + + test('should combine prompt files and skills as slash commands', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-combined'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create both prompt files and skill files + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/prompts/my-prompt.prompt.md`, + contents: [ + '---', + 'name: "my-prompt"', + 'description: "A regular prompt file"', + '---', + 'Prompt content', + ], + }, + { + path: `${rootFolder}/.github/skills/my-skill/SKILL.md`, + contents: [ + '---', + 'name: "my-skill"', + 'description: "A skill file"', + '---', + 'Skill content', + ], + }, + ]); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + const promptCommand = slashCommands.find(cmd => cmd.name === 'my-prompt'); + assert.ok(promptCommand, 'Should find prompt file as slash command'); + assert.strictEqual(promptCommand.promptPath.type, PromptsType.prompt); + + const skillCommand = slashCommands.find(cmd => cmd.name === 'my-skill'); + assert.ok(skillCommand, 'Should find skill file as slash command'); + assert.strictEqual(skillCommand.promptPath.type, PromptsType.skill); + }); + + test('should fire change event when provider registers/unregisters', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-cache-invalidation'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + const providerSkillUri = URI.parse('file://extensions/my-extension/test-skill/SKILL.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + await mockFiles(fileService, [ + { + path: providerSkillUri.path, + contents: [ + '---', + 'name: "test-skill"', + 'description: "Test skill"', + '---', + 'Test skill content', + ], + }, + ]); + + let changeEventCount = 0; + const disposable = service.onDidChangeSlashCommands(() => { + changeEventCount++; + }); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [{ uri: providerSkillUri }]; + } + }; + + // Register provider should trigger change + const registered = service.registerPromptFileProvider(extension, PromptsType.skill, provider); + await new Promise(resolve => setTimeout(resolve, 100)); + + const commandsWithProvider = await service.getPromptSlashCommands(CancellationToken.None); + const skillCommand = commandsWithProvider.find(cmd => cmd.name === 'test-skill'); + assert.ok(skillCommand, 'Should find skill from provider'); + + // Dispose provider should trigger change + registered.dispose(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const commandsAfterDispose = await service.getPromptSlashCommands(CancellationToken.None); + const skillAfterDispose = commandsAfterDispose.find(cmd => cmd.name === 'test-skill'); + assert.strictEqual(skillAfterDispose, undefined, 'Should not find skill after provider disposal'); + + assert.ok(changeEventCount >= 2, 'Change event should fire when provider registers and unregisters'); + + disposable.dispose(); + }); + + + test('should use filename as fallback for skills with missing name', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-fallback-name'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create skill without name attribute but with description + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/skills/no-name/SKILL.md`, + contents: [ + '---', + 'description: "Skill without name"', + '---', + 'Skill content', + ], + }, + { + path: `${rootFolder}/.github/skills/valid-skill/SKILL.md`, + contents: [ + '---', + 'name: "valid-skill"', + 'description: "A valid skill"', + '---', + 'Valid skill content', + ], + }, + ]); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + // Should include skill with fallback name from filename (SKILL without extension) + const fallbackNameCommand = slashCommands.find(cmd => cmd.name === 'SKILL'); + assert.ok(fallbackNameCommand, 'Should find skill with fallback name from filename'); + assert.strictEqual(fallbackNameCommand.description, 'Skill without name'); + + // Should include valid skill + const validSkillCommand = slashCommands.find(cmd => cmd.name === 'valid-skill'); + assert.ok(validSkillCommand, 'Should find valid skill'); + }); + + test('should not duplicate slash commands with same name from different types', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-no-duplicates'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create prompt and skill with same name + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/prompts/duplicate-name.prompt.md`, + contents: [ + '---', + 'name: "duplicate-name"', + 'description: "A prompt file"', + '---', + 'Prompt content', + ], + }, + { + path: `${rootFolder}/.github/skills/duplicate-name/SKILL.md`, + contents: [ + '---', + 'name: "duplicate-name"', + 'description: "A skill file"', + '---', + 'Skill content', + ], + }, + ]); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + const duplicateCommands = slashCommands.filter(cmd => cmd.name === 'duplicate-name'); + // Both should be present - the function returns all slash commands without deduplication + // This allows the caller to handle name conflicts (e.g., prompt takes precedence over skill) + assert.strictEqual(duplicateCommands.length, 2, 'Should return both prompt and skill with same name'); + + const promptCommand = duplicateCommands.find(cmd => cmd.promptPath.type === PromptsType.prompt); + assert.ok(promptCommand, 'Should find prompt command'); + + const skillCommand = duplicateCommands.find(cmd => cmd.promptPath.type === PromptsType.skill); + assert.ok(skillCommand, 'Should find skill command'); + }); + + test('should respect skill disable configuration (USE_AGENT_SKILLS)', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, false); + testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); + + const rootFolderName = 'slash-commands-skills-disabled'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Create both prompt and skill + await mockFiles(fileService, [ + { + path: `${rootFolder}/.github/prompts/my-prompt.prompt.md`, + contents: [ + '---', + 'name: "my-prompt"', + 'description: "A prompt"', + '---', + 'Prompt content', + ], + }, + { + path: `${rootFolder}/.github/skills/my-skill/SKILL.md`, + contents: [ + '---', + 'name: "my-skill"', + 'description: "A skill"', + '---', + 'Skill content', + ], + }, + ]); + + const slashCommands = await service.getPromptSlashCommands(CancellationToken.None); + + const promptCommand = slashCommands.find(cmd => cmd.name === 'my-prompt'); + assert.ok(promptCommand, 'Should find prompt command even when skills are disabled'); + + const skillCommand = slashCommands.find(cmd => cmd.name === 'my-skill'); + assert.strictEqual(skillCommand, undefined, 'Should not find skill command when skills are disabled'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts index cb408b18a05fb..cac50d438f77e 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/testUtils/mockFilesystem.ts @@ -5,8 +5,81 @@ import { URI } from '../../../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../../../base/common/buffer.js'; -import { IFileService } from '../../../../../../../platform/files/common/files.js'; +import { FileSystemProviderCapabilities, FileType, IFileService, IFileSystemProviderWithFileRealpathCapability, IStat } from '../../../../../../../platform/files/common/files.js'; import { dirname } from '../../../../../../../base/common/resources.js'; +import { InMemoryFileSystemProvider } from '../../../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { ResourceMap } from '../../../../../../../base/common/map.js'; + +/** + * Test file system provider that extends InMemoryFileSystemProvider with realpath support. + * Allows tests to define custom realpath mappings to simulate symlinks. + */ +export class TestInMemoryFileSystemProviderWithRealPath extends InMemoryFileSystemProvider implements IFileSystemProviderWithFileRealpathCapability { + private readonly realPathMappings = new ResourceMap(); + + override get capabilities(): FileSystemProviderCapabilities { + return super.capabilities | FileSystemProviderCapabilities.FileRealpath; + } + + /** + * Defines a realpath mapping for a URI. + * When realpath() is called for the given URI, it will return the mapped realPath. + * Use this to simulate symlinks - multiple URIs can map to the same realPath. + */ + setRealPath(uri: URI, realPath: URI): void { + this.realPathMappings.set(uri, realPath); + } + + /** + * Clears all realpath mappings. + */ + clearRealPathMappings(): void { + this.realPathMappings.clear(); + } + + /** + * Returns the realpath for the given resource. + * If a mapping was set via setRealPath(), returns that mapped path. + * Otherwise returns the original path (simulating a non-symlink file). + */ + async realpath(resource: URI): Promise { + const mapped = this.realPathMappings.get(resource); + if (mapped) { + return mapped.path; + } + // Default: return original path (not a symlink) + return resource.path; + } + + /** + * Override stat to mark files with realPath mappings as symbolic links. + */ + override async stat(resource: URI): Promise { + const baseStat = await super.stat(resource); + const isSymlink = this.realPathMappings.has(resource); + if (isSymlink) { + return { + ...baseStat, + type: baseStat.type | FileType.SymbolicLink + }; + } + return baseStat; + } + + /** + * Override readdir to mark files with realPath mappings as symbolic links. + */ + override async readdir(resource: URI): Promise<[string, FileType][]> { + const entries = await super.readdir(resource); + return entries.map(([name, type]) => { + const childUri = URI.joinPath(resource, name); + if (this.realPathMappings.has(childUri)) { + return [name, type | FileType.SymbolicLink]; + } + return [name, type]; + }); + } +} /** * Represents a generic file system node. diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index 050d29a767e91..5d9664f790735 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -3354,7 +3354,7 @@ suite('PromptFilesLocator', () => { return { async findAgentMDsInWorkspace(token: CancellationToken): Promise { - return locator.findAgentMDsInWorkspace(token); + return (await locator.findAgentMDsInWorkspace(token)).map(f => f.uri); }, async disposeAsync(): Promise { await mockFs.delete(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts index 2c592de63d979..855ce3a81626b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/taskHelpers.ts @@ -240,7 +240,7 @@ export async function collectTerminalResults( isActive: isActive ? () => isActive(terminalTask) : undefined, instance, dependencyTasks, - sessionId: invocationContext.sessionId + sessionResource: invocationContext.sessionResource }; // For tasks with problem matchers, wait until the task becomes busy before creating the output monitor diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 4ae019938b397..8fe480e079e99 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -11,6 +11,7 @@ import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { Disposable, MutableDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { isObject, isString } from '../../../../../../../base/common/types.js'; +import { URI } from '../../../../../../../base/common/uri.js'; import { localize } from '../../../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; import { IChatWidgetService } from '../../../../../chat/browser/chat.js'; @@ -27,7 +28,6 @@ import { getTextResponseFromStream } from './utils.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js'; import { ITerminalService } from '../../../../../terminal/browser/terminal.js'; -import { LocalChatSessionUri } from '../../../../../chat/common/model/chatUri.js'; import { ITerminalLogService } from '../../../../../../../platform/terminal/common/terminal.js'; export interface IOutputMonitor extends Disposable { @@ -547,12 +547,12 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (!suggestedOption) { return; } - const parsed = suggestedOption.replace(/['"`]/g, '').trim(); - const index = confirmationPrompt.options.indexOf(parsed); - const validOption = confirmationPrompt.options.find(opt => parsed === opt.replace(/['"`]/g, '').trim()); - if (!validOption || index === -1) { + const match = matchTerminalPromptOption(confirmationPrompt.options, suggestedOption); + if (!match.option || match.index === -1) { return; } + const validOption = match.option; + const index = match.index; let sentToTerminal = false; if (this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts)) { await this._execution.instance.sendText(validOption, true); @@ -568,7 +568,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { const focusTerminalSelection = Symbol('focusTerminalSelection'); const { promise: userPrompt, part } = this._createElicitationPart( token, - execution.sessionId, + execution.sessionResource, new MarkdownString(localize('poll.terminal.inputRequest', "The terminal is awaiting input.")), new MarkdownString(localize('poll.terminal.requireInput', "{0}\nPlease provide the required input to the terminal.\n\n", confirmationPrompt.prompt)), '', @@ -634,7 +634,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { let instanceDisposedDisposable: IDisposable = Disposable.None; const { promise: userPrompt, part } = this._createElicitationPart( token, - execution.sessionId, + execution.sessionResource, new MarkdownString(localize('poll.terminal.confirmRequired', "The terminal is awaiting input.")), new MarkdownString(localize('poll.terminal.confirmRunDetail', "{0}\n Do you want to send `{1}`{2} followed by `Enter` to the terminal?", confirmationPrompt.prompt, suggestedOptionValue, isString(suggestedOption) ? '' : suggestedOption.description ? ' (' + suggestedOption.description + ')' : '')), '', @@ -719,7 +719,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // attach additional listeners (e.g., onDidRequestHide) or compose with other promises. private _createElicitationPart( token: CancellationToken, - sessionId: string | undefined, + sessionResource: URI | undefined, title: MarkdownString, detail: MarkdownString, subtitle: string, @@ -729,7 +729,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { onReject?: () => MaybePromise, moreActions?: IAction[] | undefined ): { promise: Promise; part: ChatElicitationRequestPart } { - const chatModel = sessionId && this._chatService.getSession(LocalChatSessionUri.forSession(sessionId)); + const chatModel = sessionResource && this._chatService.getSession(sessionResource); if (!(chatModel instanceof ChatModel)) { throw new Error('No model'); } @@ -829,6 +829,39 @@ interface ISuggestedOptionResult { sentToTerminal?: boolean; } +export function matchTerminalPromptOption(options: readonly string[], suggestedOption: string): { option: string | undefined; index: number } { + const normalize = (value: string) => value.replace(/['"`]/g, '').trim().replace(/[.,:;]+$/, ''); + + const normalizedSuggestion = normalize(suggestedOption); + if (!normalizedSuggestion) { + return { option: undefined, index: -1 }; + } + + const candidates: string[] = [normalizedSuggestion]; + const firstWhitespaceToken = normalizedSuggestion.split(/\s+/)[0]; + if (firstWhitespaceToken && firstWhitespaceToken !== normalizedSuggestion) { + candidates.push(firstWhitespaceToken); + } + const firstAlphaNum = normalizedSuggestion.match(/[A-Za-z0-9]+/); + if (firstAlphaNum?.[0] && firstAlphaNum[0] !== normalizedSuggestion && firstAlphaNum[0] !== firstWhitespaceToken) { + candidates.push(firstAlphaNum[0]); + } + + for (const candidate of candidates) { + const exactIndex = options.findIndex(opt => normalize(opt) === candidate); + if (exactIndex !== -1) { + return { option: options[exactIndex], index: exactIndex }; + } + const lowerCandidate = candidate.toLowerCase(); + const ciIndex = options.findIndex(opt => normalize(opt).toLowerCase() === lowerCandidate); + if (ciIndex !== -1) { + return { option: options[ciIndex], index: ciIndex }; + } + } + + return { option: undefined, index: -1 }; +} + export function detectsInputRequiredPattern(cursorLine: string): boolean { return [ // PowerShell-style multi-option line (supports [?] Help and optional default suffix) ending diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/types.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/types.ts index 27dde5dba9cc1..477fe5e948429 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/types.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/types.ts @@ -7,6 +7,7 @@ import type { Task } from '../../../../../tasks/common/taskService.js'; import type { ITerminalInstance } from '../../../../../terminal/browser/terminal.js'; import type { ILinkLocation } from '../../taskHelpers.js'; import type { IMarker as XtermMarker } from '@xterm/xterm'; +import type { URI } from '../../../../../../../base/common/uri.js'; export interface IConfirmationPrompt { prompt: string; @@ -21,7 +22,7 @@ export interface IExecution { task?: Task | Pick; dependencyTasks?: Task[]; instance: Pick; - sessionId: string | undefined; + sessionResource: URI | undefined; } export interface IPollingResult { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index f9044cc79f8fe..3f9e6678255a9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -794,7 +794,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { OutputMonitor, { instance: toolTerminal.instance, - sessionId: invocation.context?.sessionId, + sessionResource: chatSessionResource, getOutput: (marker?: IXtermMarker) => execution.getOutput(marker ?? startMarker) }, undefined, @@ -930,6 +930,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Capture output snapshot before disposing on cancellation if (e instanceof CancellationError) { await this._commandArtifactCollector.capture(toolSpecificData, toolTerminal.instance, commandId); + // Mark the command as cancelled if it hasn't finished yet + // This ensures the decoration shows a failure icon instead of running + const state = toolSpecificData.terminalCommandState ?? {}; + if (state.exitCode === undefined) { + state.exitCode = -1; + state.timestamp = state.timestamp ?? timingStart; + state.duration = state.duration ?? Math.max(0, Date.now() - state.timestamp); + } + toolSpecificData.terminalCommandState = state; } // Clean up the execution on error RunInTerminalTool._activeExecutions.get(termId)?.dispose(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 60d8fe4aa8f70..e7eeceda2e794 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -4,11 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { detectsGenericPressAnyKeyPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; +import { detectsGenericPressAnyKeyPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { IPollingResult, OutputMonitorState } from '../../browser/tools/monitoring/types.js'; +import { IExecution, IPollingResult, OutputMonitorState } from '../../browser/tools/monitoring/types.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILanguageModelsService } from '../../../../chat/common/languageModels.js'; import { IChatService } from '../../../../chat/common/chatService/chatService.js'; @@ -24,7 +23,7 @@ import { isNumber } from '../../../../../../base/common/types.js'; suite('OutputMonitor', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let monitor: OutputMonitor; - let execution: { getOutput: () => string; isActive?: () => Promise; instance: Pick; sessionId: string }; + let execution: IExecution; let cts: CancellationTokenSource; let instantiationService: TestInstantiationService; let sendTextCalled: boolean; @@ -46,7 +45,7 @@ suite('OutputMonitor', () => { // eslint-disable-next-line local/code-no-any-casts registerMarker: () => ({ id: 1 } as any) }, - sessionId: '1' + sessionResource: LocalChatSessionUri.forSession('1') }; instantiationService = new TestInstantiationService(); @@ -287,6 +286,28 @@ suite('OutputMonitor', () => { }); }); + suite('matchTerminalPromptOption', () => { + test('matches suggested option case-insensitively', () => { + assert.deepStrictEqual(matchTerminalPromptOption(['Y', 'n'], 'y'), { option: 'Y', index: 0 }); + assert.deepStrictEqual(matchTerminalPromptOption(['y', 'N'], 'n'), { option: 'N', index: 1 }); + }); + + test('strips quotes and trailing punctuation', () => { + assert.deepStrictEqual(matchTerminalPromptOption(['Y', 'n'], '"y"'), { option: 'Y', index: 0 }); + assert.deepStrictEqual(matchTerminalPromptOption(['yes', 'no'], 'no.'), { option: 'no', index: 1 }); + }); + + test('handles bracketed options like [Y]', () => { + assert.deepStrictEqual(matchTerminalPromptOption(['Y', 'n'], '[y]'), { option: 'Y', index: 0 }); + assert.deepStrictEqual(matchTerminalPromptOption(['y', 'N'], '(n)'), { option: 'N', index: 1 }); + }); + + test('handles default suffixes by using first token', () => { + assert.deepStrictEqual(matchTerminalPromptOption(['Y', 'n'], 'Y (default)'), { option: 'Y', index: 0 }); + assert.deepStrictEqual(matchTerminalPromptOption(['Enter'], 'Enter to continue'), { option: 'Enter', index: 0 }); + }); + }); + suite('detectsVSCodeTaskFinishMessage', () => { test('detects VS Code task completion messages', () => { assert.strictEqual(detectsVSCodeTaskFinishMessage('Press any key to close the terminal.'), true); diff --git a/src/vs/workbench/contrib/webview/browser/themeing.ts b/src/vs/workbench/contrib/webview/browser/themeing.ts index 864f65fc86d51..fd4af39d746aa 100644 --- a/src/vs/workbench/contrib/webview/browser/themeing.ts +++ b/src/vs/workbench/contrib/webview/browser/themeing.ts @@ -10,6 +10,7 @@ import { IEditorOptions, EditorFontLigatures } from '../../../../editor/common/c import { EDITOR_FONT_DEFAULTS } from '../../../../editor/common/config/fontInfo.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import * as colorRegistry from '../../../../platform/theme/common/colorRegistry.js'; +import { getSizeRegistry, sizeValueToCss } from '../../../../platform/theme/common/sizeRegistry.js'; import { ColorScheme } from '../../../../platform/theme/common/theme.js'; import { IWorkbenchColorTheme, IWorkbenchThemeService } from '../../../services/themes/common/workbenchThemeService.js'; import { WebviewStyles } from './webview.js'; @@ -68,6 +69,15 @@ export class WebviewThemeDataProvider extends Disposable { return colors; }, {}); + const sizeRegistry = getSizeRegistry(); + const exportedSizes = sizeRegistry.getSizes().reduce>((sizes, entry) => { + const sizeValue = sizeRegistry.resolveDefaultSize(entry.id, theme); + if (sizeValue) { + sizes['vscode-' + entry.id.replace(/\./g, '-')] = sizeValueToCss(sizeValue); + } + return sizes; + }, {}); + const styles = { 'vscode-font-family': DEFAULT_FONT_FAMILY, 'vscode-font-weight': 'normal', @@ -77,6 +87,7 @@ export class WebviewThemeDataProvider extends Disposable { 'vscode-editor-font-size': editorFontSize + 'px', 'text-link-decoration': linkUnderlines ? 'underline' : 'none', ...exportedColors, + ...exportedSizes, 'vscode-editor-font-feature-settings': editorFontLigatures, }; diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index 04b2fde38c6e3..ff3af7ddb35a3 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -10,7 +10,7 @@ declare module 'vscode' { /** * The type of hook to execute. */ - export type ChatHookType = 'sessionStart' | 'userPromptSubmitted' | 'preToolUse' | 'postToolUse' | 'postToolUseFailure' | 'subagentStart' | 'subagentStop' | 'stop'; + export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'SubagentStart' | 'SubagentStop' | 'Stop'; /** * Options for executing a hook command. diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index a5b9e66d572a1..6086bc40b5504 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -280,6 +280,13 @@ declare module 'vscode' { }>; } + export interface ChatToolResourcesInvocationData { + /** + * Array of file URIs or locations to display as a collapsible list + */ + values: Array; + } + export class ChatToolInvocationPart { toolName: string; toolCallId: string; @@ -289,7 +296,7 @@ declare module 'vscode' { pastTenseMessage?: string | MarkdownString; isConfirmed?: boolean; isComplete?: boolean; - toolSpecificData?: ChatTerminalToolInvocationData | ChatMcpToolInvocationData | ChatTodoToolInvocationData; + toolSpecificData?: ChatTerminalToolInvocationData | ChatMcpToolInvocationData | ChatTodoToolInvocationData | ChatToolResourcesInvocationData; subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; diff --git a/test/automation/src/chat.ts b/test/automation/src/chat.ts index e95eeb072d0f0..4fe1bb73c269f 100644 --- a/test/automation/src/chat.ts +++ b/test/automation/src/chat.ts @@ -6,8 +6,8 @@ import { Code } from './code'; const CHAT_VIEW = 'div[id="workbench.panel.chat"]'; -const CHAT_EDITOR = `${CHAT_VIEW} .monaco-editor[role="code"]`; -const CHAT_EDITOR_FOCUSED = `${CHAT_VIEW} .monaco-editor.focused[role="code"]`; +const CHAT_INPUT_EDITOR = `${CHAT_VIEW} .interactive-input-part .monaco-editor[role="code"]`; +const CHAT_INPUT_EDITOR_FOCUSED = `${CHAT_VIEW} .interactive-input-part .monaco-editor.focused[role="code"]`; const CHAT_RESPONSE = `${CHAT_VIEW} .interactive-item-container.interactive-response`; const CHAT_RESPONSE_COMPLETE = `${CHAT_RESPONSE}:not(.chat-response-loading)`; const CHAT_FOOTER_DETAILS = `${CHAT_VIEW} .chat-footer-details`; @@ -17,7 +17,7 @@ export class Chat { constructor(private code: Code) { } private get chatInputSelector(): string { - return `${CHAT_EDITOR} ${!this.code.editContextEnabled ? 'textarea' : '.native-edit-context'}`; + return `${CHAT_INPUT_EDITOR} ${!this.code.editContextEnabled ? 'textarea' : '.native-edit-context'}`; } async waitForChatView(): Promise { @@ -25,12 +25,12 @@ export class Chat { } async waitForInputFocus(): Promise { - await this.code.waitForElement(CHAT_EDITOR_FOCUSED); + await this.code.waitForElement(CHAT_INPUT_EDITOR_FOCUSED); } async sendMessage(message: string): Promise { // Click on the chat input to focus it - await this.code.waitAndClick(CHAT_EDITOR); + await this.code.waitAndClick(CHAT_INPUT_EDITOR); // Wait for the editor to be focused await this.waitForInputFocus(); diff --git a/test/smoke/src/areas/accessibility/accessibility.test.ts b/test/smoke/src/areas/accessibility/accessibility.test.ts index 7f912764a0428..ac3868eb55b74 100644 --- a/test/smoke/src/areas/accessibility/accessibility.test.ts +++ b/test/smoke/src/areas/accessibility/accessibility.test.ts @@ -106,8 +106,8 @@ export function setup(logger: Logger, opts: { web?: boolean }, quality: Quality) // Wait for chat view to be visible await app.workbench.chat.waitForChatView(); - // Send a simple message - await app.workbench.chat.sendMessage('Create a simple hello.txt file with the text "Hello World"'); + // Send a simple message that does not require tools to avoid external path confirmations + await app.workbench.chat.sendMessage('Explain what "Hello World" means in programming. Include a short fenced code block that shows "Hello World".'); // Wait for the response to complete (1500 retries ~= 150 seconds at 100ms per retry) await app.workbench.chat.waitForResponse(1500); @@ -135,6 +135,8 @@ export function setup(logger: Logger, opts: { web?: boolean }, quality: Quality) // Extend timeout for this test since AI responses can take a while this.timeout(3 * 60 * 1000); + // Enable anonymous chat access + await app.workbench.settingsEditor.addUserSetting('chat.allowAnonymousAccess', 'true'); // Enable auto-approve for tools so terminal commands run automatically await app.workbench.settingsEditor.addUserSetting('chat.tools.global.autoApprove', 'true');