diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08ac9ed52c..fa8ce717f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - id: 'merge-queue-ci-skipper' uses: 'cariad-tech/merge-queue-ci-skipper@1032489e59437862c90a08a2c92809c903883772' # ratchet:cariad-tech/merge-queue-ci-skipper@main with: - secret: '${{ secrets.GITHUB_TOKEN }}' + secret: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' lint: name: 'Lint' diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index ee8ebb1764..8d511708eb 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -713,12 +713,6 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `undefined` - **Requires restart:** Yes -#### `useSmartEdit` - -- **`useSmartEdit`** (boolean): - - **Description:** Enable the smart-edit tool instead of the replace tool. - - **Default:** `true` - #### `useWriteTodos` - **`useWriteTodos`** (boolean): diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index 373392513e..fef22b8cc1 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -4,128 +4,6 @@ This guide covers security considerations, performance optimization, debugging techniques, and privacy considerations for developing and deploying hooks in Gemini CLI. -## Security considerations - -### Validate all inputs - -Never trust data from hooks without validation. Hook inputs may contain -user-provided data that could be malicious: - -```bash -#!/usr/bin/env bash -input=$(cat) - -# Validate JSON structure -if ! echo "$input" | jq empty 2>/dev/null; then - echo "Invalid JSON input" >&2 - exit 1 -fi - -# Validate required fields -tool_name=$(echo "$input" | jq -r '.tool_name // empty') -if [ -z "$tool_name" ]; then - echo "Missing tool_name field" >&2 - exit 1 -fi -``` - -### Use timeouts - -Set reasonable timeouts to prevent hooks from hanging indefinitely: - -```json -{ - "hooks": { - "BeforeTool": [ - { - "matcher": "*", - "hooks": [ - { - "name": "slow-validator", - "type": "command", - "command": "./hooks/validate.sh", - "timeout": 5000 - } - ] - } - ] - } -} -``` - -**Recommended timeouts:** - -- Fast validation: 1000-5000ms -- Network requests: 10000-30000ms -- Heavy computation: 30000-60000ms - -### Limit permissions - -Run hooks with minimal required permissions: - -```bash -#!/usr/bin/env bash -# Don't run as root -if [ "$EUID" -eq 0 ]; then - echo "Hook should not run as root" >&2 - exit 1 -fi - -# Check file permissions before writing -if [ -w "$file_path" ]; then - # Safe to write -else - echo "Insufficient permissions" >&2 - exit 1 -fi -``` - -### Scan for secrets - -Use `BeforeTool` hooks to prevent committing sensitive data: - -```javascript -const SECRET_PATTERNS = [ - /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, - /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i, - /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, - /AKIA[0-9A-Z]{16}/, // AWS access key - /ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token - /sk-[a-zA-Z0-9]{48}/, // OpenAI API key -]; - -function containsSecret(content) { - return SECRET_PATTERNS.some((pattern) => pattern.test(content)); -} -``` - -### Review external scripts - -Always review hook scripts from untrusted sources before enabling them: - -```bash -# Review before installing -cat third-party-hook.sh | less - -# Check for suspicious patterns -grep -E 'curl|wget|ssh|eval' third-party-hook.sh - -# Verify hook source -ls -la third-party-hook.sh -``` - -### Sandbox untrusted hooks - -For maximum security, consider running untrusted hooks in isolated environments: - -```bash -# Run hook in Docker container -docker run --rm \ - -v "$GEMINI_PROJECT_DIR:/workspace:ro" \ - -i untrusted-hook-image \ - /hook-script.sh < input.json -``` - ## Performance ### Keep hooks fast @@ -140,11 +18,13 @@ const data2 = await fetch(url2).then((r) => r.json()); const data3 = await fetch(url3).then((r) => r.json()); // Prefer parallel operations for better performance -const [data1, data2, data3] = await Promise.all([ - fetch(url1).then((r) => r.json()), - fetch(url2).then((r) => r.json()), - fetch(url3).then((r) => r.json()), -]); +// Start requests concurrently +const p1 = fetch(url1).then((r) => r.json()); +const p2 = fetch(url2).then((r) => r.json()); +const p3 = fetch(url3).then((r) => r.json()); + +// Wait for all results +const [data1, data2, data3] = await Promise.all([p1, p2, p3]); ``` ### Cache expensive operations @@ -714,6 +594,176 @@ if [ -f "$GEMINI_PROJECT_DIR/.env" ]; then fi ``` +## Using Hooks Securely + +### Threat Model + +Understanding where hooks come from and what they can do is critical for secure +usage. + +| Hook Source | Description | +| :---------------------------- | :------------------------------------------------------------------------------------------------------------------------- | +| **System** | Configured by system administrators (e.g., `/etc/gemini-cli/settings.json`, `/Library/...`). Assumed to be the **safest**. | +| **User** (`~/.gemini/...`) | Configured by you. You are responsible for ensuring they are safe. | +| **Extensions** | You explicitly approve and install these. Security depends on the extension source (integrity). | +| **Project** (`./.gemini/...`) | **Untrusted by default.** Safest in trusted internal repos; higher risk in third-party/public repos. | + +#### Project Hook Security + +When you open a project with hooks defined in `.gemini/settings.json`: + +1. **Detection**: Gemini CLI detects the hooks. +2. **Identification**: A unique identity is generated for each hook based on its + `name` and `command`. +3. **Warning**: If this specific hook identity has not been seen before, a + **warning** is displayed. +4. **Execution**: The hook is executed (unless specific security settings block + it). +5. **Trust**: The hook is marked as "trusted" for this project. + +> [!IMPORTANT] **Modification Detection**: If the `command` string of a project +> hook is changed (e.g., by a `git pull`), its identity changes. Gemini CLI will +> treat it as a **new, untrusted hook** and warn you again. This prevents +> malicious actors from silently swapping a verified command for a malicious +> one. + +### Risks + +| Risk | Description | +| :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | +| **Arbitrary Code Execution** | Hooks run as your user. They can do anything you can do (delete files, install software). | +| **Data Exfiltration** | A hook could read your input (prompts), output (code), or environment variables (`GEMINI_API_KEY`) and send them to a remote server. | +| **Prompt Injection** | Malicious content in a file or web page could trick an LLM into running a tool that triggers a hook in an unexpected way. | + +### Mitigation Strategies + +#### Verify the source + +**Verify the source** of any project hooks or extensions before enabling them. + +- For open-source projects, a quick review of the hook scripts is recommended. +- For extensions, ensure you trust the author or publisher (e.g., verified + publishers, well-known community members). +- Be cautious with obfuscated scripts or compiled binaries from unknown sources. + +#### Sanitize Environment + +Hooks inherit the environment of the Gemini CLI process, which may include +sensitive API keys. Gemini CLI attempts to sanitize sensitive variables, but you +should be cautious. + +- **Avoid printing environment variables** to stdout/stderr unless necessary. +- **Use `.env` files** to securely manage sensitive variables, ensuring they are + excluded from version control. + +**System Administrators:** You can enforce environment variable redaction by +default in the system configuration (e.g., `/etc/gemini-cli/settings.json`): + +```json +{ + "security": { + "environmentVariableRedaction": { + "enabled": true, + "blocked": ["MY_SECRET_KEY"], + "allowed": ["SAFE_VAR"] + } + } +} +``` + +## Authoring Secure Hooks + +When writing your own hooks, follow these practices to ensure they are robust +and secure. + +### Validate all inputs + +Never trust data from hooks without validation. Hook inputs often come from the +LLM or user prompts, which can be manipulated. + +```bash +#!/usr/bin/env bash +input=$(cat) + +# Validate JSON structure +if ! echo "$input" | jq empty 2>/dev/null; then + echo "Invalid JSON input" >&2 + exit 1 +fi + +# Validate tool_name explicitly +tool_name=$(echo "$input" | jq -r '.tool_name // empty') +if [[ "$tool_name" != "write_file" && "$tool_name" != "read_file" ]]; then + echo "Unexpected tool: $tool_name" >&2 + exit 1 +fi +``` + +### Use timeouts + +Prevent denial-of-service (hanging agents) by enforcing timeouts. Gemini CLI +defaults to 60 seconds, but you should set stricter limits for fast hooks. + +```json +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "*", + "hooks": [ + { + "name": "fast-validator", + "command": "./hooks/validate.sh", + "timeout": 5000 // 5 seconds + } + ] + } + ] + } +} +``` + +### Limit permissions + +Run hooks with minimal required permissions: + +```bash +#!/usr/bin/env bash +# Don't run as root +if [ "$EUID" -eq 0 ]; then + echo "Hook should not run as root" >&2 + exit 1 +fi + +# Check file permissions before writing +if [ -w "$file_path" ]; then + # Safe to write +else + echo "Insufficient permissions" >&2 + exit 1 +fi +``` + +### Example: Secret Scanner + +Use `BeforeTool` hooks to prevent committing sensitive data. This is a powerful +pattern for enhancing security in your workflow. + +```javascript +const SECRET_PATTERNS = [ + /api[_-]?key\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, + /password\s*[:=]\s*['"]?[^\s'"]{8,}['"]?/i, + /secret\s*[:=]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/i, + /AKIA[0-9A-Z]{16}/, // AWS access key + /ghp_[a-zA-Z0-9]{36}/, // GitHub personal access token + /sk-[a-zA-Z0-9]{48}/, // OpenAI API key +]; + +function containsSecret(content) { + return SECRET_PATTERNS.some((pattern) => pattern.test(content)); +} +``` + ## Privacy considerations Hook inputs and outputs may contain sensitive information. Gemini CLI respects diff --git a/docs/hooks/index.md b/docs/hooks/index.md index a601529aa6..48b30a721d 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -27,6 +27,28 @@ With hooks, you can: Hooks run synchronously as part of the agent loop—when a hook event fires, Gemini CLI waits for all matching hooks to complete before continuing. +## Security and Risks + +> [!WARNING] **Hooks execute arbitrary code with your user privileges.** + +By configuring hooks, you are explicitly allowing Gemini CLI to run shell +commands on your machine. Malicious or poorly configured hooks can: + +- **Exfiltrate data**: Read sensitive files (`.env`, ssh keys) and send them to + remote servers. +- **Modify system**: Delete files, install malware, or change system settings. +- **Consume resources**: Run infinite loops or crash your system. + +**Project-level hooks** (in `.gemini/settings.json`) and **Extension hooks** are +particularly risky when opening third-party projects or extensions from +untrusted authors. Gemini CLI will **warn you** the first time it detects a new +project hook (identified by its name and command), but it is **your +responsibility** to review these hooks (and any installed extensions) before +trusting them. + +See [Security Considerations](best-practices.md#using-hooks-securely) for a +detailed threat model and mitigation strategies. + ## Core concepts ### Hook events diff --git a/eslint.config.js b/eslint.config.js index 79186794bc..8f86cb6d8e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,7 +12,7 @@ import prettierConfig from 'eslint-config-prettier'; import importPlugin from 'eslint-plugin-import'; import vitest from '@vitest/eslint-plugin'; import globals from 'globals'; -import licenseHeader from 'eslint-plugin-license-header'; +import headers from 'eslint-plugin-headers'; import path from 'node:path'; import url from 'node:url'; @@ -210,19 +210,26 @@ export default tseslint.config( { files: ['./**/*.{tsx,ts,js}'], plugins: { - 'license-header': licenseHeader, + headers, import: importPlugin, }, rules: { - 'license-header/header': [ + 'headers/header-format': [ 'error', - [ - '/**', - ' * @license', - ' * Copyright 2025 Google LLC', - ' * SPDX-License-Identifier: Apache-2.0', - ' */', - ], + { + source: 'string', + content: [ + '@license', + 'Copyright (year) Google LLC', + 'SPDX-License-Identifier: Apache-2.0', + ].join('\n'), + patterns: { + year: { + pattern: '202[5-6]', + defaultValue: '2026', + }, + }, + }, ], 'import/enforce-node-protocol-usage': ['error', 'always'], }, diff --git a/package-lock.json b/package-lock.json index 84230f4ad7..18bb1df8f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,8 +36,8 @@ "esbuild-plugin-wasm": "^1.1.0", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.2", + "eslint-plugin-headers": "^1.3.3", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-license-header": "^0.8.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "glob": "^12.0.0", @@ -2500,7 +2500,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2681,7 +2680,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2715,7 +2713,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3084,7 +3081,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3118,7 +3114,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3171,7 +3166,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4409,7 +4403,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4687,7 +4680,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5699,7 +5691,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6144,7 +6135,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -7434,6 +7426,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8757,7 +8750,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8879,6 +8871,19 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-headers": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-headers/-/eslint-plugin-headers-1.3.3.tgz", + "integrity": "sha512-VzZY4+cGRoR5HpALLARH+ibIjB6a2w12/cFEayORHXMRHMzDnweSjpmvxyzX3rsSIVCg01zmvepB7Tnmaj4kGQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^16.0.0 || >= 18.0.0" + }, + "peerDependencies": { + "eslint": ">=7" + } + }, "node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", @@ -8933,16 +8938,6 @@ "semver": "bin/semver.js" } }, - "node_modules/eslint-plugin-license-header": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-license-header/-/eslint-plugin-license-header-0.8.0.tgz", - "integrity": "sha512-khTCz6G3JdoQfwrtY4XKl98KW4PpnWUKuFx8v+twIRhJADEyYglMDC0td8It75C1MZ88gcvMusWuUlJsos7gYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "requireindex": "^1.2.0" - } - }, "node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", @@ -9360,6 +9355,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -9369,6 +9365,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9378,6 +9375,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9631,6 +9629,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -9649,6 +9648,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -9657,13 +9657,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -10949,7 +10951,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.6.tgz", "integrity": "sha512-QHl6l1cl3zPCaRMzt9TUbTX6Q5SzvkGEZDDad0DmSf5SPmT1/90k6pGPejEvDCJprkitwObXpPaTWGHItqsy4g==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14143,7 +14144,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -14724,7 +14726,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14735,7 +14736,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15077,16 +15077,6 @@ "dev": true, "license": "MIT" }, - "node_modules/requireindex": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", - "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.5" - } - }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -16995,7 +16985,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17222,8 +17211,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -17231,7 +17219,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17416,7 +17403,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17579,6 +17565,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -17634,7 +17621,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17751,7 +17737,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17765,7 +17750,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18472,7 +18456,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -19036,7 +19019,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index ebeff361ef..306652221d 100644 --- a/package.json +++ b/package.json @@ -94,8 +94,8 @@ "esbuild-plugin-wasm": "^1.1.0", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.2", + "eslint-plugin-headers": "^1.3.3", "eslint-plugin-import": "^2.31.0", - "eslint-plugin-license-header": "^0.8.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "glob": "^12.0.0", diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 6ff4b37867..f427bdfe63 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -35,7 +35,8 @@ import { createStreamMessageRequest, createMockConfig, } from '../utils/testing_utils.js'; -import { MockTool } from '@google/gemini-cli-core'; +// Import MockTool from specific path to avoid vitest dependency in main core bundle +import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js'; import type { Command, CommandContext } from '../commands/types.js'; const mockToolConfirmationFn = async () => diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 32745d4c61..3d5b45df80 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -124,6 +124,12 @@ vi.mock('@google/gemini-cli-core', async () => { respectGitIgnore: true, respectGeminiIgnore: true, }, + createPolicyEngineConfig: vi.fn(async () => ({ + rules: [], + checkers: [], + defaultDecision: ServerConfig.PolicyDecision.ASK_USER, + approvalMode: ServerConfig.ApprovalMode.DEFAULT, + })), }; }); @@ -2317,3 +2323,92 @@ describe('Telemetry configuration via environment variables', () => { expect(config.getTelemetryLogPromptsEnabled()).toBe(false); }); }); + +describe('PolicyEngine nonInteractive wiring', () => { + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + process.stdin.isTTY = originalIsTTY; + vi.restoreAllMocks(); + }); + + it('should set nonInteractive to true in one-shot mode', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot + const argv = await parseArguments({} as Settings); + const config = await loadCliConfig({}, 'test-session', argv); + expect(config.isInteractive()).toBe(false); + expect( + (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) + .nonInteractive, + ).toBe(true); + }); + + it('should set nonInteractive to false in interactive mode', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js']; + const argv = await parseArguments({} as Settings); + const config = await loadCliConfig({}, 'test-session', argv); + expect(config.isInteractive()).toBe(true); + expect( + (config.getPolicyEngine() as unknown as { nonInteractive: boolean }) + .nonInteractive, + ).toBe(false); + }); +}); + +describe('Policy Engine Integration in loadCliConfig', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => { + process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool']; + const settings: Settings = { tools: { allowed: ['settings-tool'] } }; + const argv = await parseArguments({} as Settings); + + await loadCliConfig(settings, 'test-session', argv); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + allowed: expect.arrayContaining(['cli-tool']), + }), + }), + expect.anything(), + ); + }); + + it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => { + process.stdin.isTTY = false; // Non-interactive to trigger default excludes + process.argv = ['node', 'script.js', '-p', 'test']; + const settings: Settings = { tools: { exclude: ['settings-exclude'] } }; + const argv = await parseArguments({} as Settings); + + await loadCliConfig(settings, 'test-session', argv); + + // In non-interactive mode, ShellTool, etc. are excluded + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + exclude: expect.arrayContaining([SHELL_TOOL_NAME]), + }), + }), + expect.anything(), + ); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 28e1d8a629..8763f95dbf 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -73,7 +73,6 @@ export interface CliArgs { deleteSession: string | undefined; includeDirectories: string[] | undefined; screenReader: boolean | undefined; - useSmartEdit: boolean | undefined; useWriteTodos: boolean | undefined; outputFormat: string | undefined; fakeResponses: string | undefined; @@ -537,20 +536,16 @@ export async function loadCliConfig( throw err; } - const policyEngineConfig = await createPolicyEngineConfig( - settings, - approvalMode, - ); - - const allowedTools = argv.allowedTools || settings.tools?.allowed || []; - const allowedToolsSet = new Set(allowedTools); - // Interactive mode: explicit -i flag or (TTY + no args + no -p flag) const hasQuery = !!argv.query; const interactive = !!argv.promptInteractive || !!argv.experimentalAcp || (process.stdin.isTTY && !hasQuery && !argv.prompt); + + const allowedTools = argv.allowedTools || settings.tools?.allowed || []; + const allowedToolsSet = new Set(allowedTools); + // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; if (!interactive) { @@ -590,6 +585,26 @@ export async function loadCliConfig( extraExcludes.length > 0 ? extraExcludes : undefined, ); + // Create a settings object that includes CLI overrides for policy generation + const effectiveSettings: Settings = { + ...settings, + tools: { + ...settings.tools, + allowed: allowedTools, + exclude: excludeTools, + }, + mcp: { + ...settings.mcp, + allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed, + }, + }; + + const policyEngineConfig = await createPolicyEngineConfig( + effectiveSettings, + approvalMode, + ); + policyEngineConfig.nonInteractive = !interactive; + const defaultModel = settings.general?.previewFeatures ? PREVIEW_GEMINI_MODEL_AUTO : DEFAULT_GEMINI_MODEL_AUTO; @@ -689,7 +704,6 @@ export async function loadCliConfig( truncateToolOutputLines: settings.tools?.truncateToolOutputLines, enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation, eventEmitter: appEvents, - useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos, output: { format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts new file mode 100644 index 0000000000..495336e4a8 --- /dev/null +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { loadSettings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; +import { coreEvents } from '@google/gemini-cli-core'; + +const mockHomedir = vi.hoisted(() => vi.fn()); + +vi.mock('os', async (importOriginal) => { + const mockedOs = await importOriginal(); + return { + ...mockedOs, + homedir: mockHomedir, + }; +}); + +describe('ExtensionManager skills validation', () => { + let tempHomeDir: string; + let tempWorkspaceDir: string; + let userExtensionsDir: string; + let extensionManager: ExtensionManager; + + beforeEach(() => { + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-skills-test-home-'), + ); + tempWorkspaceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'gemini-cli-skills-test-workspace-'), + ); + userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(userExtensionsDir, { recursive: true }); + + mockHomedir.mockReturnValue(tempHomeDir); + + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn().mockResolvedValue(''), + settings: loadSettings(tempWorkspaceDir).merged, + }); + vi.spyOn(coreEvents, 'emitFeedback'); + }); + + afterEach(() => { + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should emit a warning during install if skills directory is not empty but no skills are loaded', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'skills-ext', + version: '1.0.0', + }); + + const skillsDir = path.join(sourceExtDir, 'skills'); + fs.mkdirSync(skillsDir); + fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello'); + + await extensionManager.loadExtensions(); + const extension = await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + + expect(extension.name).toBe('skills-ext'); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('Failed to load skills from'), + ); + }); + + it('should emit a warning during load if skills directory is not empty but no skills are loaded', async () => { + const extDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'load-skills-ext', + version: '1.0.0', + }); + + const skillsDir = path.join(extDir, 'skills'); + fs.mkdirSync(skillsDir); + fs.writeFileSync(path.join(skillsDir, 'not-a-skill.txt'), 'hello'); + + await extensionManager.loadExtensions(); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('Failed to load skills from'), + ); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + 'The directory is not empty but no valid skills were discovered', + ), + ); + }); + + it('should succeed if skills are correctly loaded', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'good-skills-ext', + version: '1.0.0', + }); + + const skillsDir = path.join(sourceExtDir, 'skills'); + const skillSubdir = path.join(skillsDir, 'test-skill'); + fs.mkdirSync(skillSubdir, { recursive: true }); + fs.writeFileSync( + path.join(skillSubdir, 'SKILL.md'), + '---\nname: test-skill\ndescription: test desc\n---\nbody', + ); + + await extensionManager.loadExtensions(); + const extension = await extensionManager.installOrUpdateExtension({ + source: sourceExtDir, + type: 'local', + }); + + expect(extension.skills).toHaveLength(1); + expect(extension.skills![0].name).toBe('test-skill'); + // It might be called for other reasons during startup, but shouldn't be called for our skills loading success + // Actually, it shouldn't be called with our warning message + expect(coreEvents.emitFeedback).not.toHaveBeenCalledWith( + 'warning', + expect.stringContaining('Failed to load skills from'), + ); + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index adc7743cf1..3dcd71dac4 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -38,6 +38,7 @@ import { logExtensionInstallEvent, logExtensionUninstall, logExtensionUpdateEvent, + loadSkillsFromDir, type ExtensionEvents, type MCPServerConfig, type ExtensionInstallMetadata, @@ -262,10 +263,17 @@ Would you like to attempt to install via "git clone" instead?`, const newHasHooks = fs.existsSync( path.join(localSourcePath, 'hooks', 'hooks.json'), ); - let previousHasHooks = false; - if (isUpdate && previous && previous.hooks) { - previousHasHooks = Object.keys(previous.hooks).length > 0; - } + const previousHasHooks = !!( + isUpdate && + previous && + previous.hooks && + Object.keys(previous.hooks).length > 0 + ); + + const newSkills = await loadSkillsFromDir( + path.join(localSourcePath, 'skills'), + ); + const previousSkills = previous?.skills ?? []; await maybeRequestConsentOrFail( newExtensionConfig, @@ -273,6 +281,8 @@ Would you like to attempt to install via "git clone" instead?`, newHasHooks, previousExtensionConfig, previousHasHooks, + newSkills, + previousSkills, ); const extensionId = getExtensionId(newExtensionConfig, installMetadata); const destinationPath = new ExtensionStorage( @@ -551,6 +561,10 @@ Would you like to attempt to install via "git clone" instead?`, }); } + const skills = await loadSkillsFromDir( + path.join(effectiveExtensionPath, 'skills'), + ); + const extension: GeminiCLIExtension = { name: config.name, version: config.version, @@ -567,6 +581,7 @@ Would you like to attempt to install via "git clone" instead?`, id: getExtensionId(config, installMetadata), settings: config.settings, resolvedSettings, + skills, }; this.loadedExtensions = [...this.loadedExtensions, extension]; @@ -721,6 +736,12 @@ Would you like to attempt to install via "git clone" instead?`, output += `\n ${tool}`; }); } + if (extension.skills && extension.skills.length > 0) { + output += `\n Agent skills:`; + extension.skills.forEach((skill) => { + output += `\n ${skill.name}: ${skill.description}`; + }); + } const resolvedSettings = extension.resolvedSettings; if (resolvedSettings && resolvedSettings.length > 0) { output += `\n Settings:`; diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index ef57161132..72a0b79fb6 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -5,15 +5,20 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import chalk from 'chalk'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; import { requestConsentNonInteractive, requestConsentInteractive, maybeRequestConsentOrFail, INSTALL_WARNING_MESSAGE, + SKILLS_WARNING_MESSAGE, } from './consent.js'; import type { ConfirmationRequest } from '../../ui/types.js'; import type { ExtensionConfig } from '../extension.js'; -import { debugLogger } from '@google/gemini-cli-core'; +import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core'; const mockReadline = vi.hoisted(() => ({ createInterface: vi.fn().mockReturnValue({ @@ -40,11 +45,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); describe('consent', () => { - beforeEach(() => { + let tempDir: string; + + beforeEach(async () => { vi.clearAllMocks(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-')); }); - afterEach(() => { + + afterEach(async () => { vi.restoreAllMocks(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); describe('requestConsentNonInteractive', () => { @@ -250,6 +262,102 @@ describe('consent', () => { ); expect(requestConsent).toHaveBeenCalledTimes(1); }); + + it('should request consent if skills change', async () => { + const skill1Dir = path.join(tempDir, 'skill1'); + const skill2Dir = path.join(tempDir, 'skill2'); + await fs.mkdir(skill1Dir, { recursive: true }); + await fs.mkdir(skill2Dir, { recursive: true }); + await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1'); + await fs.writeFile(path.join(skill1Dir, 'extra.txt'), 'extra'); + await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'body2'); + + const skill1: SkillDefinition = { + name: 'skill1', + description: 'desc1', + location: path.join(skill1Dir, 'SKILL.md'), + body: 'body1', + }; + const skill2: SkillDefinition = { + name: 'skill2', + description: 'desc2', + location: path.join(skill2Dir, 'SKILL.md'), + body: 'body2', + }; + + const config: ExtensionConfig = { + ...baseConfig, + mcpServers: { + server1: { command: 'npm', args: ['start'] }, + server2: { httpUrl: 'https://remote.com' }, + }, + contextFileName: 'my-context.md', + excludeTools: ['tool1', 'tool2'], + }; + const requestConsent = vi.fn().mockResolvedValue(true); + await maybeRequestConsentOrFail( + config, + requestConsent, + false, + undefined, + false, + [skill1, skill2], + ); + + const expectedConsentString = [ + 'Installing extension "test-ext".', + INSTALL_WARNING_MESSAGE, + 'This extension will run the following MCP servers:', + ' * server1 (local): npm start', + ' * server2 (remote): https://remote.com', + 'This extension will append info to your gemini.md context using my-context.md', + 'This extension will exclude the following core tools: tool1,tool2', + '', + chalk.bold('Agent Skills:'), + SKILLS_WARNING_MESSAGE, + 'This extension will install the following agent skills:', + ` * ${chalk.bold('skill1')}: desc1`, + ` (Location: ${skill1.location}) (2 items in directory)`, + ` * ${chalk.bold('skill2')}: desc2`, + ` (Location: ${skill2.location}) (1 items in directory)`, + '', + ].join('\n'); + + expect(requestConsent).toHaveBeenCalledWith(expectedConsentString); + }); + + it('should show a warning if the skill directory cannot be read', async () => { + const lockedDir = path.join(tempDir, 'locked'); + await fs.mkdir(lockedDir, { recursive: true, mode: 0o000 }); + + const skill: SkillDefinition = { + name: 'locked-skill', + description: 'A skill in a locked dir', + location: path.join(lockedDir, 'SKILL.md'), + body: 'body', + }; + + const requestConsent = vi.fn().mockResolvedValue(true); + try { + await maybeRequestConsentOrFail( + baseConfig, + requestConsent, + false, + undefined, + false, + [skill], + ); + + expect(requestConsent).toHaveBeenCalledWith( + expect.stringContaining( + ` (Location: ${skill.location}) ${chalk.red('⚠️ (Could not count items in directory)')}`, + ), + ); + } finally { + // Restore permissions so cleanup works + await fs.chmod(lockedDir, 0o700); + } + }); }); }); }); diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts index 36297e6386..47391fd9e6 100644 --- a/packages/cli/src/config/extensions/consent.ts +++ b/packages/cli/src/config/extensions/consent.ts @@ -4,14 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { debugLogger } from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core'; +import chalk from 'chalk'; import type { ConfirmationRequest } from '../../ui/types.js'; import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js'; import type { ExtensionConfig } from '../extension.js'; -export const INSTALL_WARNING_MESSAGE = - '**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**'; +export const INSTALL_WARNING_MESSAGE = chalk.yellow( + 'The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.', +); + +export const SKILLS_WARNING_MESSAGE = chalk.yellow( + "Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.", +); /** * Requests consent from the user to perform an action, by reading a Y/n @@ -38,7 +46,7 @@ export async function requestConsentNonInteractive( * This should not be called from non-interactive mode as it will not work. * * @param consentDescription The description of the thing they will be consenting to. - * @param setExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI. + * @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI. * @returns boolean, whether they consented or not. */ export async function requestConsentInteractive( @@ -82,7 +90,7 @@ async function promptForConsentNonInteractive( * This should not be called from non-interactive mode as it will break the CLI. * * @param prompt A markdown prompt to ask the user - * @param setExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request. + * @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request. * @returns Whether or not the user answers yes. */ async function promptForConsentInteractive( @@ -103,10 +111,11 @@ async function promptForConsentInteractive( * Builds a consent string for installing an extension based on it's * extensionConfig. */ -function extensionConsentString( +async function extensionConsentString( extensionConfig: ExtensionConfig, hasHooks: boolean, -): string { + skills: SkillDefinition[] = [], +): Promise { const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig); const output: string[] = []; const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {}); @@ -138,6 +147,24 @@ function extensionConsentString( '⚠️ This extension contains Hooks which can automatically execute commands.', ); } + if (skills.length > 0) { + output.push(`\n${chalk.bold('Agent Skills:')}`); + output.push(SKILLS_WARNING_MESSAGE); + output.push('This extension will install the following agent skills:'); + for (const skill of skills) { + output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`); + const skillDir = path.dirname(skill.location); + let fileCountStr = ''; + try { + const skillDirItems = await fs.readdir(skillDir); + fileCountStr = ` (${skillDirItems.length} items in directory)`; + } catch { + fileCountStr = ` ${chalk.red('⚠️ (Could not count items in directory)')}`; + } + output.push(` (Location: ${skill.location})${fileCountStr}`); + } + output.push(''); + } return output.join('\n'); } @@ -156,12 +183,19 @@ export async function maybeRequestConsentOrFail( hasHooks: boolean, previousExtensionConfig?: ExtensionConfig, previousHasHooks?: boolean, + skills: SkillDefinition[] = [], + previousSkills: SkillDefinition[] = [], ) { - const extensionConsent = extensionConsentString(extensionConfig, hasHooks); + const extensionConsent = await extensionConsentString( + extensionConfig, + hasHooks, + skills, + ); if (previousExtensionConfig) { - const previousExtensionConsent = extensionConsentString( + const previousExtensionConsent = await extensionConsentString( previousExtensionConfig, previousHasHooks ?? false, + previousSkills, ); if (previousExtensionConsent === extensionConsent) { return; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 097caeeb18..567053744b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1136,15 +1136,6 @@ const SETTINGS_SCHEMA = { }, }, }, - useSmartEdit: { - type: 'boolean', - label: 'Use Smart Edit', - category: 'Advanced', - requiresRestart: false, - default: true, - description: 'Enable the smart-edit tool instead of the replace tool.', - showInDialog: false, - }, useWriteTodos: { type: 'boolean', label: 'Use WriteTodos', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index a6905f736a..f98cd7c3c9 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -546,7 +546,6 @@ describe('gemini.tsx main function kitty protocol', () => { listExtensions: undefined, includeDirectories: undefined, screenReader: undefined, - useSmartEdit: undefined, useWriteTodos: undefined, resume: undefined, listSessions: undefined, diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 40bf24cbe4..1c43149440 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -4,10 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import { directoryCommand } from './directoryCommand.js'; -import { expandHomeDir } from '../utils/directoryUtils.js'; +import { + expandHomeDir, + getDirectorySuggestions, +} from '../utils/directoryUtils.js'; import type { Config, WorkspaceContext } from '@google/gemini-cli-core'; import type { MultiFolderTrustDialogProps } from '../components/MultiFolderTrustDialog.js'; import type { CommandContext, OpenCustomDialogActionReturn } from './types.js'; @@ -17,6 +20,15 @@ import * as path from 'node:path'; import * as trustedFolders from '../../config/trustedFolders.js'; import type { LoadedTrustedFolders } from '../../config/trustedFolders.js'; +vi.mock('../utils/directoryUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getDirectorySuggestions: vi.fn(), + }; +}); + describe('directoryCommand', () => { let mockContext: CommandContext; let mockConfig: Config; @@ -217,6 +229,47 @@ describe('directoryCommand', () => { expect.any(Number), ); }); + + describe('completion', () => { + const completion = addCommand!.completion!; + + it('should return empty suggestions for an empty path', async () => { + const results = await completion(mockContext, ''); + expect(results).toEqual([]); + }); + + it('should return empty suggestions for whitespace only path', async () => { + const results = await completion(mockContext, ' '); + expect(results).toEqual([]); + }); + + it('should return suggestions for a single path', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['docs/', 'src/']); + + const results = await completion(mockContext, 'd'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('d'); + expect(results).toEqual(['docs/', 'src/']); + }); + + it('should return suggestions for multiple paths', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']); + + const results = await completion(mockContext, 'docs/,s'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); + expect(results).toEqual(['docs/,src/']); + }); + + it('should handle leading whitespace in suggestions', async () => { + vi.mocked(getDirectorySuggestions).mockResolvedValue(['src/']); + + const results = await completion(mockContext, 'docs/, s'); + + expect(getDirectorySuggestions).toHaveBeenCalledWith('s'); + expect(results).toEqual(['docs/, src/']); + }); + }); }); describe('add with folder trust enabled', () => { diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index f1aa62b800..872945ecea 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -14,7 +14,10 @@ import type { SlashCommand, CommandContext } from './types.js'; import { CommandKind } from './types.js'; import { MessageType, type HistoryItem } from '../types.js'; import { refreshServerHierarchicalMemory } from '@google/gemini-cli-core'; -import { expandHomeDir } from '../utils/directoryUtils.js'; +import { + expandHomeDir, + getDirectorySuggestions, +} from '../utils/directoryUtils.js'; import type { Config } from '@google/gemini-cli-core'; async function finishAddingDirectories( @@ -80,6 +83,27 @@ export const directoryCommand: SlashCommand = { 'Add directories to the workspace. Use comma to separate multiple paths', kind: CommandKind.BUILT_IN, autoExecute: false, + showCompletionLoading: false, + completion: async (context: CommandContext, partialArg: string) => { + // Support multiple paths separated by commas + const parts = partialArg.split(','); + const lastPart = parts[parts.length - 1]; + const leadingWhitespace = lastPart.match(/^\s*/)?.[0] ?? ''; + const trimmedLastPart = lastPart.trimStart(); + + if (trimmedLastPart === '') { + return []; + } + + const suggestions = await getDirectorySuggestions(trimmedLastPart); + + if (parts.length > 1) { + const prefix = parts.slice(0, -1).join(',') + ','; + return suggestions.map((s) => prefix + leadingWhitespace + s); + } + + return suggestions.map((s) => leadingWhitespace + s); + }, action: async (context: CommandContext, args: string) => { const { ui: { addItem }, diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 22b4f58e60..85ee967143 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -13,10 +13,10 @@ import { getMCPServerStatus, getMCPDiscoveryState, DiscoveredMCPTool, + type MessageBus, } from '@google/gemini-cli-core'; import type { CallableTool } from '@google/genai'; -import { Type } from '@google/genai'; import { MessageType } from '../types.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -37,6 +37,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); +const mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), +} as unknown as MessageBus; + // Helper function to create a mock DiscoveredMCPTool const createMockMCPTool = ( name: string, @@ -50,8 +56,14 @@ const createMockMCPTool = ( } as unknown as CallableTool, serverName, name, - description || `Description for ${name}`, - { type: Type.OBJECT, properties: {} }, + description || 'Mock tool description', + { type: 'object', properties: {} }, + mockMessageBus, + undefined, // trust + undefined, // nameOverride + undefined, // cliConfig + undefined, // extensionName + undefined, // extensionId ); describe('mcpCommand', () => { diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 9d3b168b48..39339f8226 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -58,8 +58,20 @@ describe('skillsCommand', () => { expect.objectContaining({ type: MessageType.SKILLS_LIST, skills: [ - { name: 'skill1', description: 'desc1' }, - { name: 'skill2', description: 'desc2' }, + { + name: 'skill1', + description: 'desc1', + disabled: undefined, + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: undefined, + location: '/loc2', + body: 'body2', + }, ], showDescriptions: true, }), @@ -75,8 +87,20 @@ describe('skillsCommand', () => { expect.objectContaining({ type: MessageType.SKILLS_LIST, skills: [ - { name: 'skill1', description: 'desc1' }, - { name: 'skill2', description: 'desc2' }, + { + name: 'skill1', + description: 'desc1', + disabled: undefined, + location: '/loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'desc2', + disabled: undefined, + location: '/loc2', + body: 'body2', + }, ], showDescriptions: true, }), diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 42fa0afc11..e3cbc568a1 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -45,6 +45,8 @@ async function listAction( name: skill.name, description: skill.description, disabled: skill.disabled, + location: skill.location, + body: skill.body, })), showDescriptions: useShowDescriptions, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6f00695dc4..2165ab377a 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -201,5 +201,11 @@ export interface SlashCommand { partialArg: string, ) => Promise | string[]; + /** + * Whether to show the loading indicator while fetching completions. + * Defaults to true. Set to false for fast completions to avoid flicker. + */ + showCompletionLoading?: boolean; + subCommands?: SlashCommand[]; } diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 181ac31cc6..ef97c56201 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -144,10 +144,16 @@ const createMockConfig = (overrides = {}) => ({ getDebugMode: vi.fn(() => false), getAccessibility: vi.fn(() => ({})), getMcpServers: vi.fn(() => ({})), - getMcpClientManager: vi.fn().mockImplementation(() => ({ - getBlockedMcpServers: vi.fn(), - getMcpServers: vi.fn(), - })), + getToolRegistry: () => ({ + getTool: vi.fn(), + }), + getSkillManager: () => ({ + getSkills: () => [], + }), + getMcpClientManager: () => ({ + getMcpServers: () => ({}), + getBlockedMcpServers: () => [], + }), ...overrides, }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 15a2b45599..11685a4435 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -120,6 +120,7 @@ export const Composer = () => { blockedMcpServers={ config.getMcpClientManager()?.getBlockedMcpServers() ?? [] } + skillCount={config.getSkillManager().getSkills().length} /> ) )} diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index a61f57aa92..50a610f345 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -34,14 +34,16 @@ describe('', () => { openFiles: [{ path: '/a/b/c', timestamp: Date.now() }], }, }, + skillCount: 1, }; it('should render on a single line on a wide screen', () => { const { lastFrame, unmount } = renderWithWidth(120, baseProps); const output = lastFrame()!; expect(output).toContain( - 'Using: 1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server', + '1 open file (ctrl+g to view) | 1 GEMINI.md file | 1 MCP server | 1 skill', ); + expect(output).not.toContain('Using:'); // Check for absence of newlines expect(output.includes('\n')).toBe(false); unmount(); @@ -51,10 +53,10 @@ describe('', () => { const { lastFrame, unmount } = renderWithWidth(60, baseProps); const output = lastFrame()!; const expectedLines = [ - ' Using:', - ' - 1 open file (ctrl+g to view)', - ' - 1 GEMINI.md file', - ' - 1 MCP server', + ' - 1 open file (ctrl+g to view)', + ' - 1 GEMINI.md file', + ' - 1 MCP server', + ' - 1 skill', ]; const actualLines = output.split('\n'); expect(actualLines).toEqual(expectedLines); @@ -86,9 +88,10 @@ describe('', () => { geminiMdFileCount: 0, contextFileNames: [], mcpServers: {}, + skillCount: 0, }; const { lastFrame, unmount } = renderWithWidth(60, props); - const expectedLines = [' Using:', ' - 1 open file (ctrl+g to view)']; + const expectedLines = [' - 1 open file (ctrl+g to view)']; const actualLines = lastFrame()!.split('\n'); expect(actualLines).toEqual(expectedLines); unmount(); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index 5072fa4b84..39476765d4 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -17,6 +17,7 @@ interface ContextSummaryDisplayProps { mcpServers?: Record; blockedMcpServers?: Array<{ name: string; extensionName: string }>; ideContext?: IdeContext; + skillCount: number; } export const ContextSummaryDisplay: React.FC = ({ @@ -25,6 +26,7 @@ export const ContextSummaryDisplay: React.FC = ({ mcpServers, blockedMcpServers, ideContext, + skillCount, }) => { const { columns: terminalWidth } = useTerminalSize(); const isNarrow = isNarrowWidth(terminalWidth); @@ -36,7 +38,8 @@ export const ContextSummaryDisplay: React.FC = ({ geminiMdFileCount === 0 && mcpServerCount === 0 && blockedMcpServerCount === 0 && - openFileCount === 0 + openFileCount === 0 && + skillCount === 0 ) { return ; // Render an empty space to reserve height } @@ -83,15 +86,23 @@ export const ContextSummaryDisplay: React.FC = ({ return parts.join(', '); })(); - const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean); + const skillText = (() => { + if (skillCount === 0) { + return ''; + } + return `${skillCount} skill${skillCount > 1 ? 's' : ''}`; + })(); + + const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter( + Boolean, + ); if (isNarrow) { return ( - Using: {summaryParts.map((part, index) => ( - {' '}- {part} + - {part} ))} @@ -100,9 +111,7 @@ export const ContextSummaryDisplay: React.FC = ({ return ( - - Using: {summaryParts.join(' | ')} - + {summaryParts.join(' | ')} ); }; diff --git a/packages/cli/src/ui/components/views/SkillsList.test.tsx b/packages/cli/src/ui/components/views/SkillsList.test.tsx index 9d11ee241b..e04958cc9a 100644 --- a/packages/cli/src/ui/components/views/SkillsList.test.tsx +++ b/packages/cli/src/ui/components/views/SkillsList.test.tsx @@ -7,13 +7,31 @@ import { render } from '../../../test-utils/render.js'; import { describe, it, expect } from 'vitest'; import { SkillsList } from './SkillsList.js'; -import { type SkillDefinition } from '../../types.js'; +import { type SkillDefinition } from '@google/gemini-cli-core'; describe('SkillsList Component', () => { const mockSkills: SkillDefinition[] = [ - { name: 'skill1', description: 'description 1', disabled: false }, - { name: 'skill2', description: 'description 2', disabled: true }, - { name: 'skill3', description: 'description 3', disabled: false }, + { + name: 'skill1', + description: 'description 1', + disabled: false, + location: 'loc1', + body: 'body1', + }, + { + name: 'skill2', + description: 'description 2', + disabled: true, + location: 'loc2', + body: 'body2', + }, + { + name: 'skill3', + description: 'description 3', + disabled: false, + location: 'loc3', + body: 'body3', + }, ]; it('should render enabled and disabled skills separately', () => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index ae093ee56c..5e86c9b27a 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -54,6 +54,12 @@ describe('handleAtCommand', () => { const getToolRegistry = vi.fn(); + const mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as core.MessageBus; + mockConfig = { getToolRegistry, getTargetDir: () => testRootDir, @@ -94,11 +100,12 @@ describe('handleAtCommand', () => { getMcpClientManager: () => ({ getClient: () => undefined, }), + getMessageBus: () => mockMessageBus, } as unknown as Config; - const registry = new ToolRegistry(mockConfig); - registry.registerTool(new ReadManyFilesTool(mockConfig)); - registry.registerTool(new GlobTool(mockConfig)); + const registry = new ToolRegistry(mockConfig, mockMessageBus); + registry.registerTool(new ReadManyFilesTool(mockConfig, mockMessageBus)); + registry.registerTool(new GlobTool(mockConfig, mockMessageBus)); getToolRegistry.mockReturnValue(registry); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 40fb42ea44..10196a3545 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -164,7 +164,10 @@ export async function handleAtCommand({ }; const toolRegistry = config.getToolRegistry(); - const readManyFilesTool = new ReadManyFilesTool(config); + const readManyFilesTool = new ReadManyFilesTool( + config, + config.getMessageBus(), + ); const globTool = toolRegistry.getTool('glob'); if (!readManyFilesTool) { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 8847995568..7c1b3a7dc9 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -220,7 +220,6 @@ describe('useGeminiStream', () => { getContentGeneratorConfig: vi .fn() .mockReturnValue(contentGeneratorConfig), - getUseSmartEdit: () => false, isInteractive: () => false, getExperiments: () => {}, } as unknown as Config; diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index 1bf97efd4a..eb53513fdc 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -133,8 +133,7 @@ export function useQuotaAndFallback({ // Set the model to the fallback model for the current session. // This ensures the Footer updates and future turns use this model. // The change is not persisted, so the original model is restored on restart. - config.setModel(proQuotaRequest.fallbackModel, true); - + config.activateFallbackMode(proQuotaRequest.fallbackModel); historyManager.addItem( { type: MessageType.INFO, diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 5e6631f453..7809d6cf0f 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -212,7 +212,10 @@ function useCommandSuggestions( return; } - setIsLoading(true); + const showLoading = leafCommand.showCompletionLoading !== false; + if (showLoading) { + setIsLoading(true); + } try { const rawParts = [...commandPathParts]; if (partial) rawParts.push(partial); diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 68dd1122a6..7015e6afea 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -31,10 +31,10 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, ToolConfirmationOutcome, ApprovalMode, - MockTool, HookSystem, PREVIEW_GEMINI_MODEL, } from '@google/gemini-cli-core'; +import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js'; import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; import { ToolCallStatus } from '../types.js'; @@ -77,7 +77,6 @@ const mockConfig = { model: 'test-model', authType: 'oauth-personal', }), - getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }), getMessageBus: () => null, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index d3c307978c..d844ab2d33 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -13,11 +13,12 @@ import type { ToolConfirmationOutcome, ToolResultDisplay, RetrieveUserQuotaResponse, + SkillDefinition, } from '@google/gemini-cli-core'; import type { PartListUnion } from '@google/genai'; import { type ReactNode } from 'react'; -export type { ThoughtSummary }; +export type { ThoughtSummary, SkillDefinition }; export enum AuthState { // Attempting to authenticate or re-authenticate @@ -211,12 +212,6 @@ export type HistoryItemToolsList = HistoryItemBase & { showDescriptions: boolean; }; -export interface SkillDefinition { - name: string; - description: string; - disabled?: boolean; -} - export type HistoryItemSkillsList = HistoryItemBase & { type: 'skills_list'; skills: SkillDefinition[]; diff --git a/packages/cli/src/ui/utils/directoryUtils.test.ts b/packages/cli/src/ui/utils/directoryUtils.test.ts index e86c44d1fa..b001ece22c 100644 --- a/packages/cli/src/ui/utils/directoryUtils.test.ts +++ b/packages/cli/src/ui/utils/directoryUtils.test.ts @@ -4,10 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect } from 'vitest'; -import { expandHomeDir } from './directoryUtils.js'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { expandHomeDir, getDirectorySuggestions } from './directoryUtils.js'; import type * as osActual from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = @@ -33,7 +35,47 @@ vi.mock('node:os', async (importOriginal) => { }; }); +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + statSync: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ + opendir: vi.fn(), +})); + +interface MockDirent { + name: string; + isDirectory: () => boolean; +} + +function createMockDir(entries: MockDirent[]) { + let index = 0; + const iterator = { + async next() { + if (index < entries.length) { + return { value: entries[index++], done: false }; + } + return { value: undefined, done: true }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + + return { + [Symbol.asyncIterator]() { + return iterator; + }, + close: vi.fn().mockResolvedValue(undefined), + }; +} + describe('directoryUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('expandHomeDir', () => { it('should expand ~ to the home directory', () => { expect(expandHomeDir('~')).toBe(mockHomeDir); @@ -60,4 +102,216 @@ describe('directoryUtils', () => { expect(expandHomeDir('')).toBe(''); }); }); + + describe('getDirectorySuggestions', () => { + it('should return suggestions for an empty path', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'docs', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + { name: 'file.txt', isDirectory: () => false }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions(''); + expect(suggestions).toEqual([`docs${path.sep}`, `src${path.sep}`]); + }); + + it('should return suggestions for a partial path', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'docs', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('d'); + expect(suggestions).toEqual([`docs${path.sep}`]); + }); + + it('should return suggestions for a path with trailing slash', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'sub', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('docs/'); + expect(suggestions).toEqual(['docs/sub/']); + }); + + it('should return suggestions for a path with ~', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'Downloads', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('~/'); + expect(suggestions).toEqual(['~/Downloads/']); + }); + + it('should return suggestions for a partial path with ~', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'Downloads', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('~/Down'); + expect(suggestions).toEqual(['~/Downloads/']); + }); + + it('should return suggestions for ../', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'other-project', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('../'); + expect(suggestions).toEqual(['../other-project/']); + }); + + it('should ignore hidden directories', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: '.git', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions(''); + expect(suggestions).toEqual([`src${path.sep}`]); + }); + + it('should show hidden directories when filter starts with .', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: '.git', isDirectory: () => true }, + { name: '.github', isDirectory: () => true }, + { name: '.vscode', isDirectory: () => true }, + { name: 'src', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions('.g'); + expect(suggestions).toEqual([`.git${path.sep}`, `.github${path.sep}`]); + }); + + it('should return empty array if directory does not exist', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + const suggestions = await getDirectorySuggestions('nonexistent/'); + expect(suggestions).toEqual([]); + }); + + it('should limit results to 50 suggestions', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + + // Create 200 directories + const manyDirs = Array.from({ length: 200 }, (_, i) => ({ + name: `dir${String(i).padStart(3, '0')}`, + isDirectory: () => true, + })); + + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir(manyDirs) as unknown as fs.Dir, + ); + + const suggestions = await getDirectorySuggestions(''); + expect(suggestions).toHaveLength(50); + }); + + it('should terminate early after 150 matches for performance', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + + // Create 200 directories + const manyDirs = Array.from({ length: 200 }, (_, i) => ({ + name: `dir${String(i).padStart(3, '0')}`, + isDirectory: () => true, + })); + + const mockDir = createMockDir(manyDirs); + vi.mocked(fsPromises.opendir).mockResolvedValue( + mockDir as unknown as fs.Dir, + ); + + await getDirectorySuggestions(''); + + // The close method should be called, indicating early termination + expect(mockDir.close).toHaveBeenCalled(); + }); + }); + + describe.skipIf(process.platform !== 'win32')( + 'getDirectorySuggestions (Windows)', + () => { + it('should handle %userprofile% expansion', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'Documents', isDirectory: () => true }, + { name: 'Downloads', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + expect(await getDirectorySuggestions('%userprofile%\\')).toEqual([ + `%userprofile%\\Documents${path.sep}`, + `%userprofile%\\Downloads${path.sep}`, + ]); + + vi.mocked(fsPromises.opendir).mockResolvedValue( + createMockDir([ + { name: 'Documents', isDirectory: () => true }, + { name: 'Downloads', isDirectory: () => true }, + ]) as unknown as fs.Dir, + ); + + expect(await getDirectorySuggestions('%userprofile%\\Doc')).toEqual([ + `%userprofile%\\Documents${path.sep}`, + ]); + }); + }, + ); }); diff --git a/packages/cli/src/ui/utils/directoryUtils.ts b/packages/cli/src/ui/utils/directoryUtils.ts index e389042c7c..084052525b 100644 --- a/packages/cli/src/ui/utils/directoryUtils.ts +++ b/packages/cli/src/ui/utils/directoryUtils.ts @@ -6,6 +6,11 @@ import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { opendir } from 'node:fs/promises'; + +const MAX_SUGGESTIONS = 50; +const MATCH_BUFFER_MULTIPLIER = 3; export function expandHomeDir(p: string): string { if (!p) { @@ -19,3 +24,118 @@ export function expandHomeDir(p: string): string { } return path.normalize(expandedPath); } + +interface ParsedPath { + searchDir: string; + filter: string; + isHomeExpansion: boolean; + resultPrefix: string; +} + +function parsePartialPath(partialPath: string): ParsedPath { + const isHomeExpansion = partialPath.startsWith('~'); + const expandedPath = expandHomeDir(partialPath || '.'); + + let searchDir: string; + let filter: string; + + if ( + partialPath === '' || + partialPath.endsWith('/') || + partialPath.endsWith(path.sep) + ) { + searchDir = expandedPath; + filter = ''; + } else { + searchDir = path.dirname(expandedPath); + filter = path.basename(expandedPath); + + // Special case for ~ because path.dirname('~') can be '.' + if ( + isHomeExpansion && + !partialPath.includes('/') && + !partialPath.includes(path.sep) + ) { + searchDir = os.homedir(); + filter = partialPath.substring(1); + } + } + + // Calculate result prefix + let resultPrefix = ''; + if ( + partialPath === '' || + partialPath.endsWith('/') || + partialPath.endsWith(path.sep) + ) { + resultPrefix = partialPath; + } else { + const lastSlashIndex = Math.max( + partialPath.lastIndexOf('/'), + partialPath.lastIndexOf(path.sep), + ); + if (lastSlashIndex !== -1) { + resultPrefix = partialPath.substring(0, lastSlashIndex + 1); + } else if (isHomeExpansion) { + resultPrefix = `~${path.sep}`; + } + } + + return { searchDir, filter, isHomeExpansion, resultPrefix }; +} + +/** + * Gets directory suggestions based on a partial path. + * Uses async iteration with fs.opendir for efficient handling of large directories. + * + * @param partialPath The partial path typed by the user. + * @returns A promise resolving to an array of directory path suggestions. + */ +export async function getDirectorySuggestions( + partialPath: string, +): Promise { + try { + const { searchDir, filter, resultPrefix } = parsePartialPath(partialPath); + + if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) { + return []; + } + + const matches: string[] = []; + const filterLower = filter.toLowerCase(); + const showHidden = filter.startsWith('.'); + const dir = await opendir(searchDir); + + try { + for await (const entry of dir) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name.startsWith('.') && !showHidden) { + continue; + } + + if (entry.name.toLowerCase().startsWith(filterLower)) { + matches.push(entry.name); + + // Early termination with buffer for sorting + if (matches.length >= MAX_SUGGESTIONS * MATCH_BUFFER_MULTIPLIER) { + break; + } + } + } + } finally { + await dir.close().catch(() => {}); + } + + // Use the separator style from user's input for consistency + const userSep = resultPrefix.includes('/') ? '/' : path.sep; + + return matches + .sort() + .slice(0, MAX_SUGGESTIONS) + .map((name) => resultPrefix + name + userSep); + } catch (_) { + return []; + } +} diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts index 3cc0933170..f0ceec4e22 100644 --- a/packages/cli/src/zed-integration/zedIntegration.test.ts +++ b/packages/cli/src/zed-integration/zedIntegration.test.ts @@ -24,6 +24,7 @@ import { ReadManyFilesTool, type GeminiChat, type Config, + type MessageBus, } from '@google/gemini-cli-core'; import { SettingScope, type LoadedSettings } from '../config/settings.js'; import { loadCliConfig, type CliArgs } from '../config/config.js'; @@ -97,6 +98,11 @@ describe('GeminiAgent', () => { getGeminiClient: vi.fn().mockReturnValue({ startChat: vi.fn().mockResolvedValue({}), }), + getMessageBus: vi.fn().mockReturnValue({ + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }), } as unknown as Mocked>>; mockSettings = { merged: { @@ -261,6 +267,7 @@ describe('Session', () => { let session: Session; let mockToolRegistry: { getTool: Mock }; let mockTool: { kind: string; build: Mock }; + let mockMessageBus: Mocked; beforeEach(() => { mockChat = { @@ -279,6 +286,11 @@ describe('Session', () => { mockToolRegistry = { getTool: vi.fn().mockReturnValue(mockTool), }; + mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as Mocked; mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getPreviewFeatures: vi.fn().mockReturnValue({}), @@ -290,6 +302,7 @@ describe('Session', () => { getTargetDir: vi.fn().mockReturnValue('/tmp'), getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), getDebugMode: vi.fn().mockReturnValue(false), + getMessageBus: vi.fn().mockReturnValue(mockMessageBus), } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index a957f32a14..d4381efc0e 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -609,7 +609,10 @@ export class Session { const ignoredPaths: string[] = []; const toolRegistry = this.config.getToolRegistry(); - const readManyFilesTool = new ReadManyFilesTool(this.config); + const readManyFilesTool = new ReadManyFilesTool( + this.config, + this.config.getMessageBus(), + ); const globTool = toolRegistry.getTool('glob'); if (!readManyFilesTool) { diff --git a/packages/core/src/agents/delegate-to-agent-tool.test.ts b/packages/core/src/agents/delegate-to-agent-tool.test.ts index 0075cb03a7..5c8601f217 100644 --- a/packages/core/src/agents/delegate-to-agent-tool.test.ts +++ b/packages/core/src/agents/delegate-to-agent-tool.test.ts @@ -13,6 +13,7 @@ import { LocalSubagentInvocation } from './local-invocation.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { MessageBusType } from '../confirmation-bus/types.js'; import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; vi.mock('./local-invocation.js', () => ({ LocalSubagentInvocation: vi.fn().mockImplementation(() => ({ @@ -58,11 +59,7 @@ describe('DelegateToAgentTool', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (registry as any).agents.set(mockAgentDef.name, mockAgentDef); - messageBus = { - publish: vi.fn(), - subscribe: vi.fn(), - unsubscribe: vi.fn(), - } as unknown as MessageBus; + messageBus = createMockMessageBus(); tool = new DelegateToAgentTool(registry, config, messageBus); }); @@ -100,6 +97,8 @@ describe('DelegateToAgentTool', () => { config, { arg1: 'valid' }, messageBus, + mockAgentDef.name, + mockAgentDef.name, ); }); @@ -153,7 +152,7 @@ describe('DelegateToAgentTool', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (registry as any).agents.set(invalidAgentDef.name, invalidAgentDef); - expect(() => new DelegateToAgentTool(registry, config)).toThrow( + expect(() => new DelegateToAgentTool(registry, config, messageBus)).toThrow( "Agent 'invalid_agent' cannot have an input parameter named 'agent_name' as it is a reserved parameter for delegation.", ); }); diff --git a/packages/core/src/agents/delegate-to-agent-tool.ts b/packages/core/src/agents/delegate-to-agent-tool.ts index 7fa42c80a5..6ac716b7f4 100644 --- a/packages/core/src/agents/delegate-to-agent-tool.ts +++ b/packages/core/src/agents/delegate-to-agent-tool.ts @@ -30,7 +30,7 @@ export class DelegateToAgentTool extends BaseDeclarativeTool< constructor( private readonly registry: AgentRegistry, private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { const definitions = registry.getAllDefinitions(); @@ -119,20 +119,25 @@ export class DelegateToAgentTool extends BaseDeclarativeTool< registry.getToolDescription(), Kind.Think, zodToJsonSchema(schema), + messageBus, /* isOutputMarkdown */ true, /* canUpdateOutput */ true, - messageBus, ); } protected createInvocation( params: DelegateParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ): ToolInvocation { return new DelegateInvocation( params, this.registry, this.config, - this.messageBus, + messageBus, + _toolName, + _toolDisplayName, ); } } @@ -145,9 +150,16 @@ class DelegateInvocation extends BaseToolInvocation< params: DelegateParams, private readonly registry: AgentRegistry, private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ) { - super(params, messageBus, DELEGATE_TO_AGENT_TOOL_NAME); + super( + params, + messageBus, + _toolName ?? DELEGATE_TO_AGENT_TOOL_NAME, + _toolDisplayName, + ); } getDescription(): string { diff --git a/packages/core/src/agents/introspection-agent.test.ts b/packages/core/src/agents/introspection-agent.test.ts index 3e28659390..5feac834f5 100644 --- a/packages/core/src/agents/introspection-agent.test.ts +++ b/packages/core/src/agents/introspection-agent.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect } from 'vitest'; import { IntrospectionAgent } from './introspection-agent.js'; -import { GetInternalDocsTool } from '../tools/get-internal-docs.js'; +import { GET_INTERNAL_DOCS_TOOL_NAME } from '../tools/tool-names.js'; import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js'; import type { LocalAgentDefinition } from './types.js'; @@ -32,9 +32,7 @@ describe('IntrospectionAgent', () => { expect(localAgent.modelConfig?.model).toBe(GEMINI_MODEL_ALIAS_FLASH); const tools = localAgent.toolConfig?.tools || []; - const hasInternalDocsTool = tools.some( - (t) => t instanceof GetInternalDocsTool, - ); + const hasInternalDocsTool = tools.includes(GET_INTERNAL_DOCS_TOOL_NAME); expect(hasInternalDocsTool).toBe(true); }); diff --git a/packages/core/src/agents/introspection-agent.ts b/packages/core/src/agents/introspection-agent.ts index 413caa28a6..8801af6d50 100644 --- a/packages/core/src/agents/introspection-agent.ts +++ b/packages/core/src/agents/introspection-agent.ts @@ -5,7 +5,7 @@ */ import type { AgentDefinition } from './types.js'; -import { GetInternalDocsTool } from '../tools/get-internal-docs.js'; +import { GET_INTERNAL_DOCS_TOOL_NAME } from '../tools/tool-names.js'; import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js'; import { z } from 'zod'; @@ -60,7 +60,7 @@ export const IntrospectionAgent: AgentDefinition< }, toolConfig: { - tools: [new GetInternalDocsTool()], + tools: [GET_INTERNAL_DOCS_TOOL_NAME], }, promptConfig: { diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index 1a25d8bd7a..98d017c864 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -269,8 +269,13 @@ describe('LocalAgentExecutor', () => { vi.useFakeTimers(); mockConfig = makeFakeConfig(); - parentToolRegistry = new ToolRegistry(mockConfig); - parentToolRegistry.registerTool(new LSTool(mockConfig)); + parentToolRegistry = new ToolRegistry( + mockConfig, + mockConfig.getMessageBus(), + ); + parentToolRegistry.registerTool( + new LSTool(mockConfig, mockConfig.getMessageBus()), + ); parentToolRegistry.registerTool( new MockTool({ name: READ_FILE_TOOL_NAME }), ); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 3a713c0167..994c616594 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -99,7 +99,10 @@ export class LocalAgentExecutor { onActivity?: ActivityCallback, ): Promise> { // Create an isolated tool registry for this agent instance. - const agentToolRegistry = new ToolRegistry(runtimeContext); + const agentToolRegistry = new ToolRegistry( + runtimeContext, + runtimeContext.getMessageBus(), + ); const parentToolRegistry = runtimeContext.getToolRegistry(); if (definition.toolConfig) { diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 3aa5a39628..91614cea04 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -15,6 +15,7 @@ import { ToolErrorType } from '../tools/tool-error.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { type z } from 'zod'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; vi.mock('./local-executor.js'); @@ -39,10 +40,12 @@ const testDefinition: LocalAgentDefinition = { describe('LocalSubagentInvocation', () => { let mockExecutorInstance: Mocked>; + let mockMessageBus: MessageBus; beforeEach(() => { vi.clearAllMocks(); mockConfig = makeFakeConfig(); + mockMessageBus = createMockMessageBus(); mockExecutorInstance = { run: vi.fn(), @@ -55,7 +58,6 @@ describe('LocalSubagentInvocation', () => { }); it('should pass the messageBus to the parent constructor', () => { - const mockMessageBus = {} as MessageBus; const params = { task: 'Analyze data' }; const invocation = new LocalSubagentInvocation( testDefinition, @@ -76,6 +78,7 @@ describe('LocalSubagentInvocation', () => { testDefinition, mockConfig, params, + mockMessageBus, ); const description = invocation.getDescription(); expect(description).toBe( @@ -90,6 +93,7 @@ describe('LocalSubagentInvocation', () => { testDefinition, mockConfig, params, + mockMessageBus, ); const description = invocation.getDescription(); // Default INPUT_PREVIEW_MAX_LENGTH is 50 @@ -112,6 +116,7 @@ describe('LocalSubagentInvocation', () => { longNameDef, mockConfig, params, + mockMessageBus, ); const description = invocation.getDescription(); // Default DESCRIPTION_MAX_LENGTH is 200 @@ -137,6 +142,7 @@ describe('LocalSubagentInvocation', () => { testDefinition, mockConfig, params, + mockMessageBus, ); }); diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index ad27a85a61..a75fa8a11a 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -37,15 +37,22 @@ export class LocalSubagentInvocation extends BaseToolInvocation< * @param definition The definition object that configures the agent. * @param config The global runtime configuration. * @param params The validated input parameters for the agent. - * @param messageBus Optional message bus for policy enforcement. + * @param messageBus Message bus for policy enforcement. */ constructor( private readonly definition: LocalAgentDefinition, private readonly config: Config, params: AgentInputs, - messageBus?: MessageBus, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ) { - super(params, messageBus, definition.name, definition.displayName); + super( + params, + messageBus, + _toolName ?? definition.name, + _toolDisplayName ?? definition.displayName, + ); } /** diff --git a/packages/core/src/agents/remote-invocation.test.ts b/packages/core/src/agents/remote-invocation.test.ts index bbe6d15f31..610961e440 100644 --- a/packages/core/src/agents/remote-invocation.test.ts +++ b/packages/core/src/agents/remote-invocation.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest'; import type { ToolCallConfirmationDetails } from '../tools/tools.js'; import { RemoteAgentInvocation } from './remote-invocation.js'; import type { RemoteAgentDefinition } from './types.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; class TestableRemoteAgentInvocation extends RemoteAgentInvocation { override async getConfirmationDetails( @@ -29,8 +30,14 @@ describe('RemoteAgentInvocation', () => { }, }; + const mockMessageBus = createMockMessageBus(); + it('should be instantiated with correct params', () => { - const invocation = new RemoteAgentInvocation(mockDefinition, {}); + const invocation = new RemoteAgentInvocation( + mockDefinition, + {}, + mockMessageBus, + ); expect(invocation).toBeDefined(); expect(invocation.getDescription()).toBe( 'Calling remote agent Test Remote Agent', @@ -38,7 +45,11 @@ describe('RemoteAgentInvocation', () => { }); it('should return false for confirmation details (not yet implemented)', async () => { - const invocation = new TestableRemoteAgentInvocation(mockDefinition, {}); + const invocation = new TestableRemoteAgentInvocation( + mockDefinition, + {}, + mockMessageBus, + ); const details = await invocation.getConfirmationDetails( new AbortController().signal, ); diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index ee52f2f388..28ee8de6bb 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -25,9 +25,16 @@ export class RemoteAgentInvocation extends BaseToolInvocation< constructor( private readonly definition: RemoteAgentDefinition, params: AgentInputs, - messageBus?: MessageBus, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ) { - super(params, messageBus, definition.name, definition.displayName); + super( + params, + messageBus, + _toolName ?? definition.name, + _toolDisplayName ?? definition.displayName, + ); } getDescription(): string { diff --git a/packages/core/src/agents/subagent-tool-wrapper.test.ts b/packages/core/src/agents/subagent-tool-wrapper.test.ts index 382aafebf8..29a241f32e 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.test.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.test.ts @@ -13,6 +13,7 @@ import type { LocalAgentDefinition, AgentInputs } from './types.js'; import type { Config } from '../config/config.js'; import { Kind } from '../tools/tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; // Mock dependencies to isolate the SubagentToolWrapper class vi.mock('./local-invocation.js'); @@ -25,6 +26,7 @@ const mockConvertInputConfigToJsonSchema = vi.mocked( // Define reusable test data let mockConfig: Config; +let mockMessageBus: MessageBus; const mockDefinition: LocalAgentDefinition = { kind: 'local', @@ -59,6 +61,7 @@ describe('SubagentToolWrapper', () => { beforeEach(() => { vi.clearAllMocks(); mockConfig = makeFakeConfig(); + mockMessageBus = createMockMessageBus(); // Provide a mock implementation for the schema conversion utility // eslint-disable-next-line @typescript-eslint/no-explicit-any mockConvertInputConfigToJsonSchema.mockReturnValue(mockSchema as any); @@ -66,7 +69,7 @@ describe('SubagentToolWrapper', () => { describe('constructor', () => { it('should call convertInputConfigToJsonSchema with the correct agent inputConfig', () => { - new SubagentToolWrapper(mockDefinition, mockConfig); + new SubagentToolWrapper(mockDefinition, mockConfig, mockMessageBus); expect(convertInputConfigToJsonSchema).toHaveBeenCalledExactlyOnceWith( mockDefinition.inputConfig, @@ -74,7 +77,11 @@ describe('SubagentToolWrapper', () => { }); it('should correctly configure the tool properties from the agent definition', () => { - const wrapper = new SubagentToolWrapper(mockDefinition, mockConfig); + const wrapper = new SubagentToolWrapper( + mockDefinition, + mockConfig, + mockMessageBus, + ); expect(wrapper.name).toBe(mockDefinition.name); expect(wrapper.displayName).toBe(mockDefinition.displayName); @@ -92,12 +99,17 @@ describe('SubagentToolWrapper', () => { const wrapper = new SubagentToolWrapper( definitionWithoutDisplayName, mockConfig, + mockMessageBus, ); expect(wrapper.displayName).toBe(definitionWithoutDisplayName.name); }); it('should generate a valid tool schema using the definition and converted schema', () => { - const wrapper = new SubagentToolWrapper(mockDefinition, mockConfig); + const wrapper = new SubagentToolWrapper( + mockDefinition, + mockConfig, + mockMessageBus, + ); const schema = wrapper.schema; expect(schema.name).toBe(mockDefinition.name); @@ -108,7 +120,11 @@ describe('SubagentToolWrapper', () => { describe('createInvocation', () => { it('should create a LocalSubagentInvocation with the correct parameters', () => { - const wrapper = new SubagentToolWrapper(mockDefinition, mockConfig); + const wrapper = new SubagentToolWrapper( + mockDefinition, + mockConfig, + mockMessageBus, + ); const params: AgentInputs = { goal: 'Test the invocation', priority: 1 }; // The public `build` method calls the protected `createInvocation` after validation @@ -119,16 +135,22 @@ describe('SubagentToolWrapper', () => { mockDefinition, mockConfig, params, - undefined, + mockMessageBus, + mockDefinition.name, + mockDefinition.displayName, ); }); it('should pass the messageBus to the LocalSubagentInvocation constructor', () => { - const mockMessageBus = {} as MessageBus; + const specificMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as MessageBus; const wrapper = new SubagentToolWrapper( mockDefinition, mockConfig, - mockMessageBus, + specificMessageBus, ); const params: AgentInputs = { goal: 'Test the invocation', priority: 1 }; @@ -138,12 +160,18 @@ describe('SubagentToolWrapper', () => { mockDefinition, mockConfig, params, - mockMessageBus, + specificMessageBus, + mockDefinition.name, + mockDefinition.displayName, ); }); it('should throw a validation error for invalid parameters before creating an invocation', () => { - const wrapper = new SubagentToolWrapper(mockDefinition, mockConfig); + const wrapper = new SubagentToolWrapper( + mockDefinition, + mockConfig, + mockMessageBus, + ); // Missing the required 'goal' parameter const invalidParams = { priority: 1 }; diff --git a/packages/core/src/agents/subagent-tool-wrapper.ts b/packages/core/src/agents/subagent-tool-wrapper.ts index 69c96014cd..ccb0627b0b 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.ts @@ -38,7 +38,7 @@ export class SubagentToolWrapper extends BaseDeclarativeTool< constructor( private readonly definition: AgentDefinition, private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { const parameterSchema = convertInputConfigToJsonSchema( definition.inputConfig, @@ -50,9 +50,9 @@ export class SubagentToolWrapper extends BaseDeclarativeTool< definition.description, Kind.Think, parameterSchema, + messageBus, /* isOutputMarkdown */ true, /* canUpdateOutput */ true, - messageBus, ); } @@ -67,17 +67,30 @@ export class SubagentToolWrapper extends BaseDeclarativeTool< */ protected createInvocation( params: AgentInputs, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ): ToolInvocation { const definition = this.definition; + const effectiveMessageBus = messageBus; + if (definition.kind === 'remote') { - return new RemoteAgentInvocation(definition, params, this.messageBus); + return new RemoteAgentInvocation( + definition, + params, + effectiveMessageBus, + _toolName, + _toolDisplayName, + ); } return new LocalSubagentInvocation( definition, this.config, params, - this.messageBus, + effectiveMessageBus, + _toolName, + _toolDisplayName, ); } } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 28714db617..1f389cfaa0 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -61,7 +61,6 @@ vi.mock('../tools/tool-registry', () => { ToolRegistryMock.prototype.sortTools = vi.fn(); ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed ToolRegistryMock.prototype.getTool = vi.fn(); - ToolRegistryMock.prototype.setMessageBus = vi.fn(); ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []); return { ToolRegistry: ToolRegistryMock }; }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 896a4693e5..cceae41b14 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -24,7 +24,7 @@ import { ReadFileTool } from '../tools/read-file.js'; import { GrepTool } from '../tools/grep.js'; import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js'; import { GlobTool } from '../tools/glob.js'; -import { EditTool } from '../tools/edit.js'; +import { ActivateSkillTool } from '../tools/activate-skill.js'; import { SmartEditTool } from '../tools/smart-edit.js'; import { ShellTool } from '../tools/shell.js'; import { WriteFileTool } from '../tools/write-file.js'; @@ -60,8 +60,11 @@ import { ideContextStore } from '../ide/ideContext.js'; import { WriteTodosTool } from '../tools/write-todos.js'; import type { FileSystemService } from '../services/fileSystemService.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; -import { logRipgrepFallback } from '../telemetry/loggers.js'; -import { RipgrepFallbackEvent } from '../telemetry/types.js'; +import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js'; +import { + RipgrepFallbackEvent, + FlashFallbackEvent, +} from '../telemetry/types.js'; import type { FallbackModelHandler } from '../fallback/types.js'; import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js'; import { ModelRouterService } from '../routing/modelRouterService.js'; @@ -94,7 +97,7 @@ import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js'; import { getExperiments } from '../code_assist/experiments/experiments.js'; import { ExperimentFlags } from '../code_assist/experiments/flagNames.js'; import { debugLogger } from '../utils/debugLogger.js'; -import { SkillManager } from '../services/skillManager.js'; +import { SkillManager, type SkillDefinition } from '../skills/skillManager.js'; import { startupProfiler } from '../telemetry/startupProfiler.js'; import { ApprovalMode } from '../policy/types.js'; @@ -172,6 +175,7 @@ export interface GeminiCLIExtension { hooks?: { [K in HookEventName]?: HookDefinition[] }; settings?: ExtensionSetting[]; resolvedSettings?: ResolvedExtensionSetting[]; + skills?: SkillDefinition[]; } export interface ExtensionInstallMetadata { @@ -325,7 +329,6 @@ export interface ConfigParameters { truncateToolOutputLines?: number; enableToolOutputTruncation?: boolean; eventEmitter?: EventEmitter; - useSmartEdit?: boolean; useWriteTodos?: boolean; policyEngineConfig?: PolicyEngineConfig; output?: OutputSettings; @@ -450,7 +453,6 @@ export class Config { readonly storage: Storage; private readonly fileExclusions: FileExclusions; private readonly eventEmitter?: EventEmitter; - private readonly useSmartEdit: boolean; private readonly useWriteTodos: boolean; private readonly messageBus: MessageBus; private readonly policyEngine: PolicyEngine; @@ -590,7 +592,6 @@ export class Config { this.truncateToolOutputLines = params.truncateToolOutputLines ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES; this.enableToolOutputTruncation = params.enableToolOutputTruncation ?? true; - this.useSmartEdit = params.useSmartEdit ?? true; // // TODO(joshualitt): Re-evaluate the todo tool for 3 family. this.useWriteTodos = isPreviewModel(this.model) ? false @@ -732,8 +733,18 @@ export class Config { // Discover skills if enabled if (this.skillsSupport) { - await this.getSkillManager().discoverSkills(this.storage); + await this.getSkillManager().discoverSkills( + this.storage, + this.getExtensions(), + ); this.getSkillManager().setDisabledSkills(this.disabledSkills); + + // Re-register ActivateSkillTool to update its schema with the discovered enabled skill enums + if (this.getSkillManager().getSkills().length > 0) { + this.getToolRegistry().registerTool( + new ActivateSkillTool(this, this.messageBus), + ); + } } // Initialize hook system if enabled @@ -911,6 +922,14 @@ export class Config { this.modelAvailabilityService.reset(); } + activateFallbackMode(model: string): void { + this.setModel(model, true); + const authType = this.getContentGeneratorConfig()?.authType; + if (authType) { + logFlashFallback(this, new FlashFallbackEvent(authType)); + } + } + getActiveModel(): string { return this._activeModel ?? this.model; } @@ -1575,10 +1594,6 @@ export class Config { return this.truncateToolOutputLines; } - getUseSmartEdit(): boolean { - return this.useSmartEdit; - } - getUseWriteTodos(): boolean { return this.useWriteTodos; } @@ -1622,10 +1637,7 @@ export class Config { } async createToolRegistry(): Promise { - const registry = new ToolRegistry(this); - - // Set message bus on tool registry before discovery so MCP tools can access it - registry.setMessageBus(this.messageBus); + const registry = new ToolRegistry(this, this.messageBus); // helper to create & register core tools that are enabled // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1648,9 +1660,7 @@ export class Config { } if (isEnabled) { - // Pass message bus to tools when feature flag is enabled - // This first implementation is only focused on the general case of - // the tool registry. + // Pass message bus to tools (required for policy engine integration) const toolArgs = [...args, this.getMessageBus()]; registry.registerTool(new ToolClass(...toolArgs)); @@ -1679,11 +1689,8 @@ export class Config { } registerCoreTool(GlobTool, this); - if (this.getUseSmartEdit()) { - registerCoreTool(SmartEditTool, this); - } else { - registerCoreTool(EditTool, this); - } + registerCoreTool(ActivateSkillTool, this); + registerCoreTool(SmartEditTool, this); registerCoreTool(WriteFileTool, this); registerCoreTool(WebFetchTool, this); registerCoreTool(ShellTool, this); diff --git a/packages/core/src/config/flashFallback.test.ts b/packages/core/src/config/flashFallback.test.ts index b86ae68d3c..320d69c565 100644 --- a/packages/core/src/config/flashFallback.test.ts +++ b/packages/core/src/config/flashFallback.test.ts @@ -7,10 +7,16 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { Config } from './config.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js'; +import { logFlashFallback } from '../telemetry/loggers.js'; +import { FlashFallbackEvent } from '../telemetry/types.js'; import fs from 'node:fs'; vi.mock('node:fs'); +vi.mock('../telemetry/loggers.js', () => ({ + logFlashFallback: vi.fn(), + logRipgrepFallback: vi.fn(), +})); describe('Flash Model Fallback Configuration', () => { let config: Config; @@ -57,4 +63,15 @@ describe('Flash Model Fallback Configuration', () => { expect(newConfig.getModel()).toBe('custom-model'); }); }); + + describe('activateFallbackMode', () => { + it('should set model to fallback and log event', () => { + config.activateFallbackMode(DEFAULT_GEMINI_FLASH_MODEL); + expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL); + expect(logFlashFallback).toHaveBeenCalledWith( + config, + expect.any(FlashFallbackEvent), + ); + }); + }); }); diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 416f418f21..a7c51add61 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -103,6 +103,199 @@ This is custom user memory. Be extra polite." `; +exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator with tools= 1`] = ` +"You are a non-interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools. + +# Core Mandates + +- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first. +- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it. +- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project. +- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. +- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments. +- **Proactiveness:** Fulfill the user's request thoroughly. When adding features or fixing bugs, this includes adding tests to ensure quality. Consider all created files, especially tests, to be permanent artifacts unless the user says otherwise. +- **Handle Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request. +- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked. +- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes. + - **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information. + +Mock Agent Directory + +# Primary Workflows + +## Software Engineering Tasks +When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence: +1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. +Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'. +2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution. +3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. +5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. +6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction. + +## New Applications + +**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'replace' and 'run_shell_command'. + +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. +2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. + - When key technologies aren't specified, prefer the following: + - **Websites (Frontend):** React (JavaScript/TypeScript) or Angular with Bootstrap CSS, incorporating Material Design principles for UI/UX. + - **Back-End APIs:** Node.js with Express.js (JavaScript/TypeScript) or Python with FastAPI. + - **Full-stack:** Next.js (React/Node.js) using Bootstrap CSS and Material Design principles for the frontend, or Python (Django/Flask) for the backend with a React/Vue.js/Angular frontend styled with Bootstrap CSS and Material Design principles. + - **CLIs:** Python or Go. + - **Mobile App:** Compose Multiplatform (Kotlin Multiplatform) or Flutter (Dart) using Material Design libraries and principles, when sharing code between Android and iOS. Jetpack Compose (Kotlin JVM) with Material Design principles or SwiftUI (Swift) for native apps targeted at either Android or iOS, respectively. + - **3d Games:** HTML/CSS/JavaScript with Three.js. + - **2d Games:** HTML/CSS/JavaScript. +3. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'run_shell_command' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. +4. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors. + +# Operational Guidelines + +## Shell tool output token efficiency: + +IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. + +- Always prefer command flags that reduce output verbosity when using 'run_shell_command'. +- Aim to minimize tool output tokens while still capturing necessary information. +- If a command is expected to produce a lot of output, use quiet or silent flags where available and appropriate. +- Always consider the trade-off between output verbosity and the need for information. If a command's full output is essential for understanding the result, avoid overly aggressive quieting that might obscure important details. +- If a command does not have quiet/silent flags or for commands with potentially long output that may not be useful, redirect stdout and stderr to temp files in the project's temporary directory. For example: 'command > /out.log 2> /err.log'. +- After the command runs, inspect the temp files (e.g. '/out.log' and '/err.log') using commands like 'grep', 'tail', 'head', ... (or platform equivalents). Remove the temp files when done. + + +## Tone and Style (CLI Interaction) +- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment. +- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query. +- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous. +- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer. +- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace. +- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself. +- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. + +## Security and Safety Rules +- **Explain Critical Commands:** Before executing commands with 'run_shell_command' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. + +## Tool Usage +- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). +- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. +- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. +- **Interactive Commands:** Only execute non-interactive commands. +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. +- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. + +## Interaction Details +- **Help Command:** The user can use '/help' to display help information. +- **Feedback:** To report a bug or provide feedback, please use the /bug command. + + +# Outside of Sandbox +You are running outside of a sandbox container, directly on the user's system. For critical commands that are particularly likely to modify the user's system outside of the project directory or system temp directory, as you explain the command to the user (per the Explain Critical Commands rule above), also remind the user to consider enabling sandboxing. + + + + +# Final Reminder +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +`; + +exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator with tools=codebase_investigator 1`] = ` +"You are a non-interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools. + +# Core Mandates + +- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first. +- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it. +- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project. +- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. +- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments. +- **Proactiveness:** Fulfill the user's request thoroughly. When adding features or fixing bugs, this includes adding tests to ensure quality. Consider all created files, especially tests, to be permanent artifacts unless the user says otherwise. +- **Handle Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request. +- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked. +- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes. + - **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information. + +Mock Agent Directory + +# Primary Workflows + +## Software Engineering Tasks +When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence: +1. **Understand & Strategize:** Think about the user's request and the relevant codebase context. When the task involves **complex refactoring, codebase exploration or system-wide analysis**, your **first and primary action** must be to delegate to the 'codebase_investigator' agent using the 'delegate_to_agent' tool. Use it to build a comprehensive understanding of the code, its structure, and dependencies. For **simple, targeted searches** (like finding a specific function name, file path, or variable declaration), you should use 'search_file_content' or 'glob' directly. +2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. If 'codebase_investigator' was used, do not ignore the output of the agent, you must use it as the foundation of your plan. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution. +3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. +5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. +6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction. + +## New Applications + +**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'replace' and 'run_shell_command'. + +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. +2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. + - When key technologies aren't specified, prefer the following: + - **Websites (Frontend):** React (JavaScript/TypeScript) or Angular with Bootstrap CSS, incorporating Material Design principles for UI/UX. + - **Back-End APIs:** Node.js with Express.js (JavaScript/TypeScript) or Python with FastAPI. + - **Full-stack:** Next.js (React/Node.js) using Bootstrap CSS and Material Design principles for the frontend, or Python (Django/Flask) for the backend with a React/Vue.js/Angular frontend styled with Bootstrap CSS and Material Design principles. + - **CLIs:** Python or Go. + - **Mobile App:** Compose Multiplatform (Kotlin Multiplatform) or Flutter (Dart) using Material Design libraries and principles, when sharing code between Android and iOS. Jetpack Compose (Kotlin JVM) with Material Design principles or SwiftUI (Swift) for native apps targeted at either Android or iOS, respectively. + - **3d Games:** HTML/CSS/JavaScript with Three.js. + - **2d Games:** HTML/CSS/JavaScript. +3. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'run_shell_command' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. +4. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors. + +# Operational Guidelines + +## Shell tool output token efficiency: + +IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. + +- Always prefer command flags that reduce output verbosity when using 'run_shell_command'. +- Aim to minimize tool output tokens while still capturing necessary information. +- If a command is expected to produce a lot of output, use quiet or silent flags where available and appropriate. +- Always consider the trade-off between output verbosity and the need for information. If a command's full output is essential for understanding the result, avoid overly aggressive quieting that might obscure important details. +- If a command does not have quiet/silent flags or for commands with potentially long output that may not be useful, redirect stdout and stderr to temp files in the project's temporary directory. For example: 'command > /out.log 2> /err.log'. +- After the command runs, inspect the temp files (e.g. '/out.log' and '/err.log') using commands like 'grep', 'tail', 'head', ... (or platform equivalents). Remove the temp files when done. + + +## Tone and Style (CLI Interaction) +- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment. +- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query. +- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous. +- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer. +- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace. +- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself. +- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. + +## Security and Safety Rules +- **Explain Critical Commands:** Before executing commands with 'run_shell_command' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. + +## Tool Usage +- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). +- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. +- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. +- **Interactive Commands:** Only execute non-interactive commands. +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. +- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. + +## Interaction Details +- **Help Command:** The user can use '/help' to display help information. +- **Feedback:** To report a bug or provide feedback, please use the /bug command. + + +# Outside of Sandbox +You are running outside of a sandbox container, directly on the user's system. For critical commands that are particularly likely to modify the user's system outside of the project directory or system temp directory, as you explain the command to the user (per the Explain Critical Commands rule above), also remind the user to consider enabling sandboxing. + + + + +# Final Reminder +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +`; + exports[`Core System Prompt (prompts.ts) > should handle git instructions when isGitRepository=false 1`] = ` "You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools. @@ -310,6 +503,117 @@ You are running outside of a sandbox container, directly on the user's system. F - Never push changes to a remote repository without being asked explicitly by the user. +# Final Reminder +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +`; + +exports[`Core System Prompt (prompts.ts) > should include available_skills when provided in config 1`] = ` +"You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools. + +# Core Mandates + +- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first. +- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it. +- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project. +- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. +- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments. +- **Proactiveness:** Fulfill the user's request thoroughly. When adding features or fixing bugs, this includes adding tests to ensure quality. Consider all created files, especially tests, to be permanent artifacts unless the user says otherwise. +- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it. +- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked. +- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes. +- **Skill Guidance:** Once a skill is activated via \`activate_skill\`, its instructions and resources are returned wrapped in \`\` tags. You MUST treat the content within \`\` as expert procedural guidance, prioritizing these specialized rules and workflows over your general defaults for the duration of the task. You may utilize any listed \`\` as needed. Follow this expert guidance strictly while continuing to uphold your core safety and security standards. + +Mock Agent Directory +# Available Agent Skills + +You have access to the following specialized skills. To activate a skill and receive its detailed instructions, you can call the \`activate_skill\` tool with the skill's name. + + + + test-skill + A test skill description + /path/to/test-skill/SKILL.md + + + + +# Primary Workflows + +## Software Engineering Tasks +When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence: +1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. +Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'. +2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution. +3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. +5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. +6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction. + +## New Applications + +**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'replace' and 'run_shell_command'. + +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. + - When key technologies aren't specified, prefer the following: + - **Websites (Frontend):** React (JavaScript/TypeScript) or Angular with Bootstrap CSS, incorporating Material Design principles for UI/UX. + - **Back-End APIs:** Node.js with Express.js (JavaScript/TypeScript) or Python with FastAPI. + - **Full-stack:** Next.js (React/Node.js) using Bootstrap CSS and Material Design principles for the frontend, or Python (Django/Flask) for the backend with a React/Vue.js/Angular frontend styled with Bootstrap CSS and Material Design principles. + - **CLIs:** Python or Go. + - **Mobile App:** Compose Multiplatform (Kotlin Multiplatform) or Flutter (Dart) using Material Design libraries and principles, when sharing code between Android and iOS. Jetpack Compose (Kotlin JVM) with Material Design principles or SwiftUI (Swift) for native apps targeted at either Android or iOS, respectively. + - **3d Games:** HTML/CSS/JavaScript with Three.js. + - **2d Games:** HTML/CSS/JavaScript. +3. **User Approval:** Obtain user approval for the proposed plan. +4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'run_shell_command' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. +5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors. +6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype. + +# Operational Guidelines + +## Shell tool output token efficiency: + +IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. + +- Always prefer command flags that reduce output verbosity when using 'run_shell_command'. +- Aim to minimize tool output tokens while still capturing necessary information. +- If a command is expected to produce a lot of output, use quiet or silent flags where available and appropriate. +- Always consider the trade-off between output verbosity and the need for information. If a command's full output is essential for understanding the result, avoid overly aggressive quieting that might obscure important details. +- If a command does not have quiet/silent flags or for commands with potentially long output that may not be useful, redirect stdout and stderr to temp files in the project's temporary directory. For example: 'command > /out.log 2> /err.log'. +- After the command runs, inspect the temp files (e.g. '/out.log' and '/err.log') using commands like 'grep', 'tail', 'head', ... (or platform equivalents). Remove the temp files when done. + + +## Tone and Style (CLI Interaction) +- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment. +- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query. +- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous. +- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer. +- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace. +- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself. +- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. + +## Security and Safety Rules +- **Explain Critical Commands:** Before executing commands with 'run_shell_command' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. + +## Tool Usage +- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). +- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. +- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Interactive Commands:** Prefer non-interactive commands when it makes sense; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. + +## Interaction Details +- **Help Command:** The user can use '/help' to display help information. +- **Feedback:** To report a bug or provide feedback, please use the /bug command. + + +# Outside of Sandbox +You are running outside of a sandbox container, directly on the user's system. For critical commands that are particularly likely to modify the user's system outside of the project directory or system temp directory, as you explain the command to the user (per the Explain Critical Commands rule above), also remind the user to consider enabling sandboxing. + + + + # Final Reminder Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; @@ -898,6 +1202,104 @@ You are running outside of a sandbox container, directly on the user's system. F +# Final Reminder +Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." +`; + +exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for preview flash model 1`] = ` +"You are an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools. + +# Core Mandates + +- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first. +- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it. +- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project. +- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. +- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments. +- **Proactiveness:** Fulfill the user's request thoroughly. When adding features or fixing bugs, this includes adding tests to ensure quality. Consider all created files, especially tests, to be permanent artifacts unless the user says otherwise. +- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it. +- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked. +- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes. +- **Do not call tools in silence:** You must provide to the user very short and concise natural explanation (one sentence) before calling tools. + +Mock Agent Directory + +# Primary Workflows + +## Software Engineering Tasks +When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence: +1. **Understand:** Think about the user's request and the relevant codebase context. Use 'search_file_content' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. +Use 'read_file' to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to 'read_file'. +2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should use an iterative development process that includes writing unit tests to verify your changes. Use output logs or debug statements as part of this process to arrive at a solution. +3. **Implement:** Use the available tools (e.g., 'replace', 'write_file' 'run_shell_command' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. +5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. +6. **Finalize:** After all verification passes, consider the task complete. Do not remove or revert any changes or created files (like tests). Await the user's next instruction. + +## New Applications + +**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'replace' and 'run_shell_command'. + +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. + - When key technologies aren't specified, prefer the following: + - **Websites (Frontend):** React (JavaScript/TypeScript) or Angular with Bootstrap CSS, incorporating Material Design principles for UI/UX. + - **Back-End APIs:** Node.js with Express.js (JavaScript/TypeScript) or Python with FastAPI. + - **Full-stack:** Next.js (React/Node.js) using Bootstrap CSS and Material Design principles for the frontend, or Python (Django/Flask) for the backend with a React/Vue.js/Angular frontend styled with Bootstrap CSS and Material Design principles. + - **CLIs:** Python or Go. + - **Mobile App:** Compose Multiplatform (Kotlin Multiplatform) or Flutter (Dart) using Material Design libraries and principles, when sharing code between Android and iOS. Jetpack Compose (Kotlin JVM) with Material Design principles or SwiftUI (Swift) for native apps targeted at either Android or iOS, respectively. + - **3d Games:** HTML/CSS/JavaScript with Three.js. + - **2d Games:** HTML/CSS/JavaScript. +3. **User Approval:** Obtain user approval for the proposed plan. +4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'run_shell_command' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. +5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors. +6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype. + +# Operational Guidelines + +## Shell tool output token efficiency: + +IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. + +- Always prefer command flags that reduce output verbosity when using 'run_shell_command'. +- Aim to minimize tool output tokens while still capturing necessary information. +- If a command is expected to produce a lot of output, use quiet or silent flags where available and appropriate. +- Always consider the trade-off between output verbosity and the need for information. If a command's full output is essential for understanding the result, avoid overly aggressive quieting that might obscure important details. +- If a command does not have quiet/silent flags or for commands with potentially long output that may not be useful, redirect stdout and stderr to temp files in the project's temporary directory. For example: 'command > /out.log 2> /err.log'. +- After the command runs, inspect the temp files (e.g. '/out.log' and '/err.log') using commands like 'grep', 'tail', 'head', ... (or platform equivalents). Remove the temp files when done. + + +## Tone and Style (CLI Interaction) +- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment. +- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query. +- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous. +- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace. +- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself. +- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. + +## Security and Safety Rules +- **Explain Critical Commands:** Before executing commands with 'run_shell_command' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. + +## Tool Usage +- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). +- **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. +- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. +- **Interactive Commands:** Prefer non-interactive commands when it makes sense; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`ctrl + f\` to focus into the shell to provide input. +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. + +## Interaction Details +- **Help Command:** The user can use '/help' to display help information. +- **Feedback:** To report a bug or provide feedback, please use the /bug command. + + +# Outside of Sandbox +You are running outside of a sandbox container, directly on the user's system. For critical commands that are particularly likely to modify the user's system outside of the project directory or system temp directory, as you explain the command to the user (per the Explain Critical Commands rule above), also remind the user to consider enabling sandboxing. + + + + # Final Reminder Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read_file' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved." `; diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 653608bb17..34facc4737 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -251,7 +251,6 @@ describe('Gemini Client (client.ts)', () => { getEnableHooks: vi.fn().mockReturnValue(false), getChatCompression: vi.fn().mockReturnValue(undefined), getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false), - getUseSmartEdit: vi.fn().mockReturnValue(false), getShowModelInfoInChat: vi.fn().mockReturnValue(false), getContinueOnFailedApiCall: vi.fn(), getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), diff --git a/packages/core/src/core/coreToolHookTriggers.test.ts b/packages/core/src/core/coreToolHookTriggers.test.ts index 68a4357523..403f11339a 100644 --- a/packages/core/src/core/coreToolHookTriggers.test.ts +++ b/packages/core/src/core/coreToolHookTriggers.test.ts @@ -19,8 +19,8 @@ import { } from '../confirmation-bus/types.js'; class MockInvocation extends BaseToolInvocation<{ key?: string }, ToolResult> { - constructor(params: { key?: string }) { - super(params); + constructor(params: { key?: string }, messageBus: MessageBus) { + super(params, messageBus); } getDescription() { return 'mock'; @@ -47,12 +47,14 @@ describe('executeToolWithHooks', () => { unsubscribe: vi.fn(), } as unknown as MessageBus; mockTool = { - build: vi.fn().mockImplementation((params) => new MockInvocation(params)), + build: vi + .fn() + .mockImplementation((params) => new MockInvocation(params, messageBus)), } as unknown as AnyDeclarativeTool; }); it('should prioritize continue: false over decision: block in BeforeTool', async () => { - const invocation = new MockInvocation({}); + const invocation = new MockInvocation({}, messageBus); const abortSignal = new AbortController().signal; vi.mocked(messageBus.request).mockResolvedValue({ @@ -81,7 +83,7 @@ describe('executeToolWithHooks', () => { }); it('should block execution in BeforeTool if decision is block', async () => { - const invocation = new MockInvocation({}); + const invocation = new MockInvocation({}, messageBus); const abortSignal = new AbortController().signal; vi.mocked(messageBus.request).mockResolvedValue({ @@ -108,7 +110,7 @@ describe('executeToolWithHooks', () => { }); it('should handle continue: false in AfterTool', async () => { - const invocation = new MockInvocation({}); + const invocation = new MockInvocation({}, messageBus); const abortSignal = new AbortController().signal; const spy = vi.spyOn(invocation, 'execute'); @@ -146,7 +148,7 @@ describe('executeToolWithHooks', () => { }); it('should block result in AfterTool if decision is deny', async () => { - const invocation = new MockInvocation({}); + const invocation = new MockInvocation({}, messageBus); const abortSignal = new AbortController().signal; // BeforeTool allow @@ -183,7 +185,7 @@ describe('executeToolWithHooks', () => { it('should apply modified tool input from BeforeTool hook', async () => { const params = { key: 'original' }; - const invocation = new MockInvocation(params); + const invocation = new MockInvocation(params, messageBus); const toolName = 'test-tool'; const abortSignal = new AbortController().signal; @@ -235,7 +237,7 @@ describe('executeToolWithHooks', () => { it('should not modify input if hook does not provide tool_input', async () => { const params = { key: 'original' }; - const invocation = new MockInvocation(params); + const invocation = new MockInvocation(params, messageBus); const toolName = 'test-tool'; const abortSignal = new AbortController().signal; diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index a5f340eeeb..ba4e22506b 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -20,6 +20,7 @@ import type { Config, ToolRegistry, AnyToolInvocation, + MessageBus, } from '../index.js'; import { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, @@ -48,7 +49,10 @@ vi.mock('fs/promises', () => ({ class TestApprovalTool extends BaseDeclarativeTool<{ id: string }, ToolResult> { static readonly Name = 'testApprovalTool'; - constructor(private config: Config) { + constructor( + private config: Config, + messageBus: MessageBus, + ) { super( TestApprovalTool.Name, 'TestApprovalTool', @@ -59,13 +63,17 @@ class TestApprovalTool extends BaseDeclarativeTool<{ id: string }, ToolResult> { required: ['id'], type: 'object', }, + messageBus, ); } - protected createInvocation(params: { - id: string; - }): ToolInvocation<{ id: string }, ToolResult> { - return new TestApprovalInvocation(this.config, params); + protected createInvocation( + params: { id: string }, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ): ToolInvocation<{ id: string }, ToolResult> { + return new TestApprovalInvocation(this.config, params, messageBus); } } @@ -76,8 +84,9 @@ class TestApprovalInvocation extends BaseToolInvocation< constructor( private config: Config, params: { id: string }, + messageBus: MessageBus, ) { - super(params); + super(params, messageBus); } getDescription(): string { @@ -124,8 +133,9 @@ class AbortDuringConfirmationInvocation extends BaseToolInvocation< private readonly abortController: AbortController, private readonly abortError: Error, params: Record, + messageBus: MessageBus, ) { - super(params); + super(params, messageBus); } override async shouldConfirmExecute( @@ -151,6 +161,7 @@ class AbortDuringConfirmationTool extends BaseDeclarativeTool< constructor( private readonly abortController: AbortController, private readonly abortError: Error, + messageBus: MessageBus, ) { super( 'abortDuringConfirmationTool', @@ -161,16 +172,21 @@ class AbortDuringConfirmationTool extends BaseDeclarativeTool< type: 'object', properties: {}, }, + messageBus, ); } protected createInvocation( params: Record, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ): ToolInvocation, ToolResult> { return new AbortDuringConfirmationInvocation( this.abortController, this.abortError, params, + messageBus, ); } } @@ -255,7 +271,6 @@ function createMockConfig(overrides: Partial = {}): Config { getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getToolRegistry: () => defaultToolRegistry, getActiveModel: () => DEFAULT_GEMINI_MODEL, - getUseSmartEdit: () => false, getGeminiClient: () => null, getMessageBus: () => createMockMessageBus(), getEnableHooks: () => false, @@ -533,6 +548,7 @@ describe('CoreToolScheduler', () => { const declarativeTool = new AbortDuringConfirmationTool( abortController, abortError, + createMockMessageBus(), ); const mockToolRegistry = { @@ -728,8 +744,8 @@ class MockEditToolInvocation extends BaseToolInvocation< Record, ToolResult > { - constructor(params: Record) { - super(params); + constructor(params: Record, messageBus: MessageBus) { + super(params, messageBus); } getDescription(): string { @@ -764,20 +780,30 @@ class MockEditTool extends BaseDeclarativeTool< Record, ToolResult > { - constructor() { - super('mockEditTool', 'mockEditTool', 'A mock edit tool', Kind.Edit, {}); + constructor(messageBus: MessageBus) { + super( + 'mockEditTool', + 'mockEditTool', + 'A mock edit tool', + Kind.Edit, + {}, + messageBus, + ); } protected createInvocation( params: Record, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ): ToolInvocation, ToolResult> { - return new MockEditToolInvocation(params); + return new MockEditToolInvocation(params, messageBus); } } describe('CoreToolScheduler edit cancellation', () => { it('should preserve diff when an edit is cancelled', async () => { - const mockEditTool = new MockEditTool(); + const mockEditTool = new MockEditTool(createMockMessageBus()); const mockToolRegistry = { getTool: () => mockEditTool, getFunctionDeclarations: () => [], @@ -1346,7 +1372,7 @@ describe('CoreToolScheduler request queueing', () => { .fn() .mockReturnValue(new HookSystem(mockConfig)); - const testTool = new TestApprovalTool(mockConfig); + const testTool = new TestApprovalTool(mockConfig, mockMessageBus); const toolRegistry = { getTool: () => testTool, getFunctionDeclarations: () => [], diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index a4f9524ec7..a903bfdfa1 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -63,7 +63,6 @@ describe('executeToolCall', () => { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, getActiveModel: () => PREVIEW_GEMINI_MODEL, - getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests getMessageBus: () => null, getPolicyEngine: () => null, diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index cb346b01c3..ceb5019df8 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -72,9 +72,52 @@ describe('Core System Prompt (prompts.ts)', () => { getAgentRegistry: vi.fn().mockReturnValue({ getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'), }), + getSkillManager: vi.fn().mockReturnValue({ + getSkills: vi.fn().mockReturnValue([]), + }), } as unknown as Config; }); + it('should include available_skills when provided in config', () => { + const skills = [ + { + name: 'test-skill', + description: 'A test skill description', + location: '/path/to/test-skill/SKILL.md', + body: 'Skill content', + }, + ]; + vi.mocked(mockConfig.getSkillManager().getSkills).mockReturnValue(skills); + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).toContain('# Available Agent Skills'); + expect(prompt).toContain( + "To activate a skill and receive its detailed instructions, you can call the `activate_skill` tool with the skill's name.", + ); + expect(prompt).toContain('Skill Guidance'); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toContain('test-skill'); + expect(prompt).toContain( + 'A test skill description', + ); + expect(prompt).toContain( + '/path/to/test-skill/SKILL.md', + ); + expect(prompt).toContain(''); + expect(prompt).toContain(''); + expect(prompt).toMatchSnapshot(); + }); + + it('should NOT include skill guidance or available_skills when NO skills are provided', () => { + vi.mocked(mockConfig.getSkillManager().getSkills).mockReturnValue([]); + const prompt = getCoreSystemPrompt(mockConfig); + + expect(prompt).not.toContain('# Available Agent Skills'); + expect(prompt).not.toContain('Skill Guidance'); + expect(prompt).not.toContain('activate_skill'); + }); + it('should use chatty system prompt for preview model', () => { vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL); const prompt = getCoreSystemPrompt(mockConfig); @@ -88,7 +131,9 @@ describe('Core System Prompt (prompts.ts)', () => { PREVIEW_GEMINI_FLASH_MODEL, ); const prompt = getCoreSystemPrompt(mockConfig); - expect(prompt).toContain('Do not call tools in silence'); + expect(prompt).toContain('You are an interactive CLI agent'); // Check for core content + expect(prompt).not.toContain('No Chitchat:'); + expect(prompt).toMatchSnapshot(); }); it.each([ @@ -175,6 +220,9 @@ describe('Core System Prompt (prompts.ts)', () => { getAgentRegistry: vi.fn().mockReturnValue({ getDirectoryContext: vi.fn().mockReturnValue('Mock Agent Directory'), }), + getSkillManager: vi.fn().mockReturnValue({ + getSkills: vi.fn().mockReturnValue([]), + }), } as unknown as Config; const prompt = getCoreSystemPrompt(testConfig); @@ -194,6 +242,7 @@ describe('Core System Prompt (prompts.ts)', () => { "Use 'search_file_content' and 'glob' search tools extensively", ); } + expect(prompt).toMatchSnapshot(); }, ); diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 0165ad6228..42271256dc 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -17,6 +17,7 @@ import { WRITE_FILE_TOOL_NAME, WRITE_TODOS_TOOL_NAME, DELEGATE_TO_AGENT_TOOL_NAME, + ACTIVATE_SKILL_TOOL_NAME, } from '../tools/tool-names.js'; import process from 'node:process'; import { isGitRepository } from '../utils/gitUtils.js'; @@ -130,6 +131,30 @@ export function getCoreSystemPrompt( const interactiveMode = config.isInteractiveShellEnabled(); + const skills = config.getSkillManager().getSkills(); + let skillsPrompt = ''; + if (skills.length > 0) { + const skillsXml = skills + .map( + (skill) => ` + ${skill.name} + ${skill.description} + ${skill.location} + `, + ) + .join('\n'); + + skillsPrompt = ` +# Available Agent Skills + +You have access to the following specialized skills. To activate a skill and receive its detailed instructions, you can call the \`${ACTIVATE_SKILL_TOOL_NAME}\` tool with the skill's name. + + +${skillsXml} + +`; + } + let basePrompt: string; if (systemMdEnabled) { basePrompt = fs.readFileSync(systemMdPath, 'utf8'); @@ -147,14 +172,19 @@ export function getCoreSystemPrompt( - **Proactiveness:** Fulfill the user's request thoroughly. When adding features or fixing bugs, this includes adding tests to ensure quality. Consider all created files, especially tests, to be permanent artifacts unless the user says otherwise. - ${interactiveMode ? `**Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.` : `**Handle Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request.`} - **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked. -- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${mandatesVariant}${ +- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.${ + skills.length > 0 + ? ` +- **Skill Guidance:** Once a skill is activated via \`${ACTIVATE_SKILL_TOOL_NAME}\`, its instructions and resources are returned wrapped in \`\` tags. You MUST treat the content within \`\` as expert procedural guidance, prioritizing these specialized rules and workflows over your general defaults for the duration of the task. You may utilize any listed \`\` as needed. Follow this expert guidance strictly while continuing to uphold your core safety and security standards.` + : '' + }${mandatesVariant}${ !interactiveMode ? ` - **Continue the work** You are not to interact with the user. Do your best to complete the task at hand, using your best judgement and avoid asking user for any additional information.` : '' } -${config.getAgentRegistry().getDirectoryContext()}`, +${config.getAgentRegistry().getDirectoryContext()}${skillsPrompt}`, primaryWorkflows_prefix: ` # Primary Workflows @@ -366,7 +396,7 @@ Your core function is efficient and safe assistance. Balance extreme conciseness process.env['GEMINI_WRITE_SYSTEM_MD'], ); - // Check if the feature is enabled. This proceeds only if the environment + // Write the base prompt to a file if the GEMINI_WRITE_SYSTEM_MD environment // variable is set and is not explicitly '0' or 'false'. if (writeSystemMdResolution.value && !writeSystemMdResolution.isDisabled) { const writePath = writeSystemMdResolution.isSwitch @@ -377,6 +407,8 @@ Your core function is efficient and safe assistance. Balance extreme conciseness fs.writeFileSync(writePath, basePrompt); } + basePrompt = basePrompt.trim(); + const memorySuffix = userMemory && userMemory.trim().length > 0 ? `\n\n---\n\n${userMemory.trim()}` diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index e30a57ba87..c6032298f6 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -18,6 +18,7 @@ import { SessionStartSource, type HookExecutionResult, } from './types.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; // Mock debugLogger const mockDebugLogger = vi.hoisted(() => ({ @@ -92,6 +93,7 @@ describe('HookEventHandler', () => { mockHookPlanner, mockHookRunner, mockHookAggregator, + createMockMessageBus(), ); }); diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index e36bd3719a..92268b7f51 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -280,7 +280,7 @@ export class HookEventHandler { private readonly hookPlanner: HookPlanner; private readonly hookRunner: HookRunner; private readonly hookAggregator: HookAggregator; - private readonly messageBus?: MessageBus; + private readonly messageBus: MessageBus; constructor( config: Config, @@ -288,7 +288,7 @@ export class HookEventHandler { hookPlanner: HookPlanner, hookRunner: HookRunner, hookAggregator: HookAggregator, - messageBus?: MessageBus, + messageBus: MessageBus, ) { this.config = config; this.hookPlanner = hookPlanner; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0165fffcbf..4cf9df113a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -93,6 +93,8 @@ export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; export * from './services/contextManager.js'; +export * from './skills/skillManager.js'; +export * from './skills/skillLoader.js'; // Export IDE specific logic export * from './ide/ide-client.js'; @@ -155,9 +157,6 @@ export { Storage } from './config/storage.js'; // Export hooks system export * from './hooks/index.js'; -// Export test utils -export * from './test-utils/index.js'; - // Export hook types export * from './hooks/types.js'; diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index b8e76f73b6..a3e6126734 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -858,4 +858,41 @@ name = "invalid-name" // Priority 10 in default tier → 1.010 expect(discoveredRule?.priority).toBeCloseTo(1.01, 5); }); + + it('should normalize legacy "ShellTool" alias to "run_shell_command"', async () => { + vi.resetModules(); + + // Mock fs to return empty for policies + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + const mockReaddir = vi.fn( + async () => [] as unknown as Awaited>, + ); + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readdir: mockReaddir }, + readdir: mockReaddir, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + const settings: PolicySettings = { + tools: { allowed: ['ShellTool'] }, + }; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + '/tmp/mock/default/policies', + ); + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + + vi.doUnmock('node:fs/promises'); + }); }); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 8d74589992..ff09b31d41 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -16,11 +16,8 @@ import { type PolicySettings, } from './types.js'; import type { PolicyEngine } from './policy-engine.js'; -import { - loadPoliciesFromToml, - type PolicyFileError, - escapeRegex, -} from './toml-loader.js'; +import { loadPoliciesFromToml, type PolicyFileError } from './toml-loader.js'; +import { buildArgsPatterns } from './utils.js'; import toml from '@iarna/toml'; import { MessageBusType, @@ -29,6 +26,8 @@ import { import { type MessageBus } from '../confirmation-bus/message-bus.js'; import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js'; +import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -195,11 +194,48 @@ export async function createPolicyEngineConfig( // Priority: 2.3 (user tier - explicit temporary allows) if (settings.tools?.allowed) { for (const tool of settings.tools.allowed) { - rules.push({ - toolName: tool, - decision: PolicyDecision.ALLOW, - priority: 2.3, - }); + // Check for legacy format: toolName(args) + const match = tool.match(/^([a-zA-Z0-9_-]+)\((.*)\)$/); + if (match) { + const [, rawToolName, args] = match; + // Normalize shell tool aliases + const toolName = SHELL_TOOL_NAMES.includes(rawToolName) + ? SHELL_TOOL_NAME + : rawToolName; + + // Treat args as a command prefix for shell tool + if (toolName === SHELL_TOOL_NAME) { + const patterns = buildArgsPatterns(undefined, args); + for (const pattern of patterns) { + if (pattern) { + rules.push({ + toolName, + decision: PolicyDecision.ALLOW, + priority: 2.3, + argsPattern: new RegExp(pattern), + }); + } + } + } else { + // For non-shell tools, we allow the tool itself but ignore args + // as args matching was only supported for shell tools historically. + rules.push({ + toolName, + decision: PolicyDecision.ALLOW, + priority: 2.3, + }); + } + } else { + // Standard tool name + const toolName = SHELL_TOOL_NAMES.includes(tool) + ? SHELL_TOOL_NAME + : tool; + rules.push({ + toolName, + decision: PolicyDecision.ALLOW, + priority: 2.3, + }); + } } } @@ -263,26 +299,19 @@ export function createPolicyUpdater( if (message.commandPrefix) { // Convert commandPrefix(es) to argsPatterns for in-memory rules - const prefixes = Array.isArray(message.commandPrefix) - ? message.commandPrefix - : [message.commandPrefix]; - - for (const prefix of prefixes) { - const escapedPrefix = escapeRegex(prefix); - // Use robust regex to match whole words (e.g. "git" but not "github") - const argsPattern = new RegExp( - `"command":"${escapedPrefix}(?:[\\s"]|$)`, - ); - - policyEngine.addRule({ - toolName, - decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 - // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, - argsPattern, - }); + const patterns = buildArgsPatterns(undefined, message.commandPrefix); + for (const pattern of patterns) { + if (pattern) { + policyEngine.addRule({ + toolName, + decision: PolicyDecision.ALLOW, + // User tier (2) + high priority (950/1000) = 2.95 + // This ensures user "always allow" selections are high priority + // but still lose to admin policies (3.xxx) and settings excludes (200) + priority: 2.95, + argsPattern: new RegExp(pattern), + }); + } } } else { const argsPattern = message.argsPattern diff --git a/packages/core/src/policy/persistence.test.ts b/packages/core/src/policy/persistence.test.ts index 7743de752f..479ae8707c 100644 --- a/packages/core/src/policy/persistence.test.ts +++ b/packages/core/src/policy/persistence.test.ts @@ -127,7 +127,7 @@ describe('createPolicyUpdater', () => { expect(addedRule).toBeDefined(); expect(addedRule?.priority).toBe(2.95); expect(addedRule?.argsPattern).toEqual( - new RegExp(`"command":"git status(?:[\\s"]|$)`), + new RegExp(`"command":"git\\ status(?:[\\s"]|$)`), ); // Verify file written diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 09387b59c1..991424cebc 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -56,6 +56,11 @@ toolName = "write_file" decision = "ask_user" priority = 10 +[[rule]] +toolName = "activate_skill" +decision = "ask_user" +priority = 10 + [[rule]] toolName = "write_file" decision = "allow" diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index a362d1995a..33dc77f00f 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; import { PolicyEngine } from './policy-engine.js'; import { PolicyDecision, @@ -17,11 +17,40 @@ import { import type { FunctionCall } from '@google/genai'; import { SafetyCheckDecision } from '../safety/protocol.js'; import type { CheckerRunner } from '../safety/checker-runner.js'; +import { initializeShellParsers } from '../utils/shell-utils.js'; +import { buildArgsPatterns } from './utils.js'; + +// Mock shell-utils to ensure consistent behavior across platforms (especially Windows CI) +// We want to test PolicyEngine logic, not the shell parser's ability to parse commands +vi.mock('../utils/shell-utils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + initializeShellParsers: vi.fn().mockResolvedValue(undefined), + splitCommands: vi.fn().mockImplementation((command: string) => { + // Simple mock splitting logic for test cases + if (command.includes('&&')) { + return command.split('&&').map((c) => c.trim()); + } + return [command]; + }), + hasRedirection: vi.fn().mockImplementation( + (command: string) => + // Simple mock: true if '>' is present, unless it looks like "-> arrow" + command.includes('>') && !command.includes('-> arrow'), + ), + }; +}); describe('PolicyEngine', () => { let engine: PolicyEngine; let mockCheckerRunner: CheckerRunner; + beforeAll(async () => { + await initializeShellParsers(); + }); + beforeEach(() => { mockCheckerRunner = { runChecker: vi.fn(), @@ -457,6 +486,29 @@ describe('PolicyEngine', () => { ); }); + it('should correctly match commands with quotes in commandPrefix', async () => { + const prefix = 'git commit -m "fix"'; + const patterns = buildArgsPatterns(undefined, prefix); + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + argsPattern: new RegExp(patterns[0]!), + decision: PolicyDecision.ALLOW, + }, + ]; + engine = new PolicyEngine({ rules }); + + const result = await engine.check( + { + name: 'run_shell_command', + args: { command: 'git commit -m "fix"' }, + }, + undefined, + ); + + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + it('should handle tools with no args', async () => { const rules: PolicyRule[] = [ { @@ -864,6 +916,338 @@ describe('PolicyEngine', () => { (await engine.check({ name: 'test', args }, undefined)).decision, ).toBe(PolicyDecision.ALLOW); }); + it('should downgrade ALLOW to ASK_USER for redirected shell commands', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + // Matches "echo" prefix + argsPattern: /"command":"echo/, + decision: PolicyDecision.ALLOW, + }, + ]; + + engine = new PolicyEngine({ rules }); + + // Safe command should be allowed + expect( + ( + await engine.check( + { name: 'run_shell_command', args: { command: 'echo "hello"' } }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + + // Redirected command should be downgraded to ASK_USER + expect( + ( + await engine.check( + { + name: 'run_shell_command', + args: { command: 'echo "hello" > file.txt' }, + }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ASK_USER); + }); + + it('should allow redirected shell commands when allowRedirection is true', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + // Matches "echo" prefix + argsPattern: /"command":"echo/, + decision: PolicyDecision.ALLOW, + allowRedirection: true, + }, + ]; + + engine = new PolicyEngine({ rules }); + + // Redirected command should stay ALLOW + expect( + ( + await engine.check( + { + name: 'run_shell_command', + args: { command: 'echo "hello" > file.txt' }, + }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + }); + + it('should NOT downgrade ALLOW to ASK_USER for quoted redirection chars', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + argsPattern: /"command":"echo/, + decision: PolicyDecision.ALLOW, + }, + ]; + + engine = new PolicyEngine({ rules }); + + // Should remain ALLOW because it's not a real redirection + expect( + ( + await engine.check( + { + name: 'run_shell_command', + args: { command: 'echo "-> arrow"' }, + }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + }); + + it('should preserve dir_path during recursive shell command checks', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + // Rule that only allows echo in a specific directory + // Note: stableStringify sorts keys alphabetically and has no spaces: {"command":"echo hello","dir_path":"/safe/path"} + argsPattern: /"command":"echo hello".*"dir_path":"\/safe\/path"/, + decision: PolicyDecision.ALLOW, + }, + { + // Catch-all ALLOW for shell but with low priority + toolName: 'run_shell_command', + decision: PolicyDecision.ALLOW, + priority: -100, + }, + ]; + + engine = new PolicyEngine({ rules }); + + // Compound command. The decomposition will call check() for "echo hello" + // which should match our specific high-priority rule IF dir_path is preserved. + const result = await engine.check( + { + name: 'run_shell_command', + args: { command: 'echo hello && pwd', dir_path: '/safe/path' }, + }, + undefined, + ); + + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should upgrade ASK_USER to ALLOW if all sub-commands are allowed', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + argsPattern: /"command":"git status/, + decision: PolicyDecision.ALLOW, + priority: 20, + }, + { + toolName: 'run_shell_command', + argsPattern: /"command":"ls/, + decision: PolicyDecision.ALLOW, + priority: 20, + }, + { + // Catch-all ASK_USER for shell + toolName: 'run_shell_command', + decision: PolicyDecision.ASK_USER, + priority: 10, + }, + ]; + + engine = new PolicyEngine({ rules }); + + // "git status && ls" matches the catch-all ASK_USER rule initially. + // But since both parts are explicitly ALLOWed, the result should be upgraded to ALLOW. + const result = await engine.check( + { + name: 'run_shell_command', + args: { command: 'git status && ls' }, + }, + undefined, + ); + + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should respect explicit DENY for compound commands even if parts are allowed', async () => { + const rules: PolicyRule[] = [ + { + // Explicitly DENY the compound command + toolName: 'run_shell_command', + argsPattern: /"command":"git status && ls"/, + decision: PolicyDecision.DENY, + priority: 30, + }, + { + toolName: 'run_shell_command', + argsPattern: /"command":"git status/, + decision: PolicyDecision.ALLOW, + priority: 20, + }, + { + toolName: 'run_shell_command', + argsPattern: /"command":"ls/, + decision: PolicyDecision.ALLOW, + priority: 20, + }, + ]; + + engine = new PolicyEngine({ rules }); + + const result = await engine.check( + { + name: 'run_shell_command', + args: { command: 'git status && ls' }, + }, + undefined, + ); + + expect(result.decision).toBe(PolicyDecision.DENY); + }); + + it('should propagate DENY from any sub-command', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + argsPattern: /"command":"rm/, + decision: PolicyDecision.DENY, + priority: 20, + }, + { + toolName: 'run_shell_command', + argsPattern: /"command":"echo/, + decision: PolicyDecision.ALLOW, + priority: 20, + }, + { + toolName: 'run_shell_command', + decision: PolicyDecision.ASK_USER, + priority: 10, + }, + ]; + + engine = new PolicyEngine({ rules }); + + // "echo hello && rm -rf /" -> echo is ALLOW, rm is DENY -> Result DENY + const result = await engine.check( + { + name: 'run_shell_command', + args: { command: 'echo hello && rm -rf /' }, + }, + undefined, + ); + + expect(result.decision).toBe(PolicyDecision.DENY); + }); + + it('should DENY redirected shell commands in non-interactive mode', async () => { + const config: PolicyEngineConfig = { + nonInteractive: true, + rules: [ + { + toolName: 'run_shell_command', + decision: PolicyDecision.ALLOW, + }, + ], + }; + + engine = new PolicyEngine(config); + + // Redirected command should be DENIED in non-interactive mode + // (Normally ASK_USER, but ASK_USER -> DENY in non-interactive) + expect( + ( + await engine.check( + { + name: 'run_shell_command', + args: { command: 'echo "hello" > file.txt' }, + }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should default to ASK_USER for atomic commands when matching a wildcard ASK_USER rule', async () => { + // Regression test: atomic commands were auto-allowing because of optimistic initialization + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + decision: PolicyDecision.ASK_USER, + }, + ]; + + engine = new PolicyEngine({ rules }); + + // Atomic command "whoami" matches the wildcard rule (ASK_USER). + // It should NOT be upgraded to ALLOW. + expect( + ( + await engine.check( + { + name: 'run_shell_command', + args: { command: 'whoami' }, + }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ASK_USER); + }); + + it('should allow redirected shell commands in non-interactive mode if allowRedirection is true', async () => { + const config: PolicyEngineConfig = { + nonInteractive: true, + rules: [ + { + toolName: 'run_shell_command', + decision: PolicyDecision.ALLOW, + allowRedirection: true, + }, + ], + }; + + engine = new PolicyEngine(config); + + // Redirected command should stay ALLOW even in non-interactive mode + expect( + ( + await engine.check( + { + name: 'run_shell_command', + args: { command: 'echo "hello" > file.txt' }, + }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + }); + + it('should avoid infinite recursion for commands with substitution', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'run_shell_command', + decision: PolicyDecision.ALLOW, + }, + ]; + + engine = new PolicyEngine({ rules }); + + // Command with substitution triggers splitCommands returning the same command as its first element. + // This verifies the fix for the infinite recursion bug. + const result = await engine.check( + { + name: 'run_shell_command', + args: { command: 'echo $(ls)' }, + }, + undefined, + ); + + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); }); describe('safety checker integration', () => { diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 06e7adc00b..13a3ef5225 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -24,6 +24,7 @@ import { SHELL_TOOL_NAMES, initializeShellParsers, splitCommands, + hasRedirection, } from '../utils/shell-utils.js'; function ruleMatches( @@ -140,6 +141,111 @@ export class PolicyEngine { return this.approvalMode; } + /** + * Check if a shell command is allowed. + */ + private async checkShellCommand( + toolName: string, + command: string | undefined, + ruleDecision: PolicyDecision, + serverName: string | undefined, + dir_path: string | undefined, + allowRedirection?: boolean, + ): Promise { + if (!command) { + return this.applyNonInteractiveMode(ruleDecision); + } + + await initializeShellParsers(); + const subCommands = splitCommands(command); + + if (subCommands.length === 0) { + debugLogger.debug( + `[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ASK_USER.`, + ); + return this.applyNonInteractiveMode(PolicyDecision.ASK_USER); + } + + // If there are multiple parts, or if we just want to validate the single part against DENY rules + if (subCommands.length > 0) { + debugLogger.debug( + `[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`, + ); + + if (ruleDecision === PolicyDecision.DENY) { + return PolicyDecision.DENY; + } + + // Start optimistically. If all parts are ALLOW, the whole is ALLOW. + // We will downgrade if any part is ASK_USER or DENY. + let aggregateDecision = PolicyDecision.ALLOW; + + for (const subCmd of subCommands) { + // Prevent infinite recursion for the root command + if (subCmd === command) { + if (!allowRedirection && hasRedirection(subCmd)) { + debugLogger.debug( + `[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`, + ); + // Redirection always downgrades ALLOW to ASK_USER + if (aggregateDecision === PolicyDecision.ALLOW) { + aggregateDecision = PolicyDecision.ASK_USER; + } + } else { + // If the command is atomic (cannot be split further) and didn't + // trigger infinite recursion checks, we must respect the decision + // of the rule that triggered this check. If the rule was ASK_USER + // (e.g. wildcard), we must downgrade. + if ( + ruleDecision === PolicyDecision.ASK_USER && + aggregateDecision === PolicyDecision.ALLOW + ) { + aggregateDecision = PolicyDecision.ASK_USER; + } + } + continue; + } + + const subResult = await this.check( + { name: toolName, args: { command: subCmd, dir_path } }, + serverName, + ); + + // subResult.decision is already filtered through applyNonInteractiveMode by this.check() + const subDecision = subResult.decision; + + // If any part is DENIED, the whole command is DENIED + if (subDecision === PolicyDecision.DENY) { + return PolicyDecision.DENY; + } + + // If any part requires ASK_USER, the whole command requires ASK_USER + if (subDecision === PolicyDecision.ASK_USER) { + if (aggregateDecision === PolicyDecision.ALLOW) { + aggregateDecision = PolicyDecision.ASK_USER; + } + } + + // Check for redirection in allowed sub-commands + if ( + subDecision === PolicyDecision.ALLOW && + !allowRedirection && + hasRedirection(subCmd) + ) { + debugLogger.debug( + `[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`, + ); + if (aggregateDecision === PolicyDecision.ALLOW) { + aggregateDecision = PolicyDecision.ASK_USER; + } + } + } + return this.applyNonInteractiveMode(aggregateDecision); + } + + return this.applyNonInteractiveMode(ruleDecision); + } + /** * Check if a tool call is allowed based on the configured policies. * Returns the decision and the matching rule (if any). @@ -183,62 +289,16 @@ export class PolicyEngine { `[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`, ); - // Special handling for shell commands: check sub-commands if present - if ( - toolCall.name && - SHELL_TOOL_NAMES.includes(toolCall.name) && - rule.decision === PolicyDecision.ALLOW - ) { - const command = (toolCall.args as { command?: string })?.command; - if (command) { - await initializeShellParsers(); - const subCommands = splitCommands(command); - - // If there are multiple sub-commands, we must verify EACH of them matches an ALLOW rule. - // If any sub-command results in DENY -> the whole thing is DENY. - // If any sub-command results in ASK_USER -> the whole thing is ASK_USER (unless one is DENY). - // Only if ALL sub-commands are ALLOW do we proceed with ALLOW. - if (subCommands.length === 0) { - // This case occurs if the command is non-empty but parsing fails. - // An ALLOW rule for a prefix might have matched, but since the rest of - // the command is un-parseable, it's unsafe to proceed. - // Fall back to a safe decision. - debugLogger.debug( - `[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to safe decision because implicit ALLOW is unsafe.`, - ); - decision = this.applyNonInteractiveMode(PolicyDecision.ASK_USER); - } else if (subCommands.length > 1) { - debugLogger.debug( - `[PolicyEngine.check] Compound command detected: ${subCommands.length} parts`, - ); - let aggregateDecision = PolicyDecision.ALLOW; - - for (const subCmd of subCommands) { - // Recursively check each sub-command - const subCall = { - name: toolCall.name, - args: { command: subCmd }, - }; - const subResult = await this.check(subCall, serverName); - - if (subResult.decision === PolicyDecision.DENY) { - aggregateDecision = PolicyDecision.DENY; - break; // Fail fast - } else if (subResult.decision === PolicyDecision.ASK_USER) { - aggregateDecision = PolicyDecision.ASK_USER; - // efficient: we can only strictly downgrade from ALLOW to ASK_USER, - // but we must continue looking for DENY. - } - } - - decision = aggregateDecision; - } else { - // Single command, rule match is valid - decision = this.applyNonInteractiveMode(rule.decision); - } - } else { - decision = this.applyNonInteractiveMode(rule.decision); - } + if (toolCall.name && SHELL_TOOL_NAMES.includes(toolCall.name)) { + const args = toolCall.args as { command?: string; dir_path?: string }; + decision = await this.checkShellCommand( + toolCall.name, + args?.command, + rule.decision, + serverName, + args?.dir_path, + rule.allowRedirection, + ); } else { decision = this.applyNonInteractiveMode(rule.decision); } diff --git a/packages/core/src/policy/policy-updater.test.ts b/packages/core/src/policy/policy-updater.test.ts index acde845e3a..e5add3748a 100644 --- a/packages/core/src/policy/policy-updater.test.ts +++ b/packages/core/src/policy/policy-updater.test.ts @@ -152,7 +152,6 @@ describe('ShellToolInvocation Policy Update', () => { const invocation = new ShellToolInvocation( mockConfig, { command: 'git status && npm test' }, - new Set(), mockMessageBus, 'run_shell_command', 'Shell', @@ -174,7 +173,6 @@ describe('ShellToolInvocation Policy Update', () => { const invocation = new ShellToolInvocation( mockConfig, { command: 'ls -la /tmp' }, - new Set(), mockMessageBus, 'run_shell_command', 'Shell', diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index 1bb41fdfd6..53b05fec25 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -157,6 +157,21 @@ modes = ["yolo"] expect(result.errors).toHaveLength(0); }); + it('should parse and transform allow_redirection property', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "echo" +decision = "allow" +priority = 100 +allow_redirection = true +`); + + expect(result.rules).toHaveLength(1); + expect(result.rules[0].allowRedirection).toBe(true); + expect(result.errors).toHaveLength(0); + }); + it('should return error if modes property is used for Tier 2 and Tier 3 policies', async () => { await fs.writeFile( path.join(tempDir, 'tier2.toml'), diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index 162a250906..edb4614ff6 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -12,6 +12,7 @@ import { type SafetyCheckerRule, InProcessCheckerType, } from './types.js'; +import { buildArgsPatterns } from './utils.js'; import fs from 'node:fs/promises'; import path from 'node:path'; import toml from '@iarna/toml'; @@ -44,6 +45,7 @@ const PolicyRuleSchema = z.object({ 'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.', }), modes: z.array(z.nativeEnum(ApprovalMode)).optional(), + allow_redirection: z.boolean().optional(), }); /** @@ -119,17 +121,6 @@ export interface PolicyLoadResult { errors: PolicyFileError[]; } -/** - * Escapes special regex characters in a string for use in a regex pattern. - * This is used for commandPrefix to ensure literal string matching. - * - * @param str The string to escape - * @returns The escaped string safe for use in a regex - */ -export function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - /** * Converts a tier number to a human-readable tier name. */ @@ -354,26 +345,11 @@ export async function loadPoliciesFromToml( // Transform rules const parsedRules: PolicyRule[] = (validationResult.data.rule ?? []) .flatMap((rule) => { - // Transform commandPrefix/commandRegex to argsPattern - let effectiveArgsPattern = rule.argsPattern; - const commandPrefixes: string[] = []; - - if (rule.commandPrefix) { - const prefixes = Array.isArray(rule.commandPrefix) - ? rule.commandPrefix - : [rule.commandPrefix]; - commandPrefixes.push(...prefixes); - } else if (rule.commandRegex) { - effectiveArgsPattern = `"command":"${rule.commandRegex}`; - } - - // Expand command prefixes to multiple patterns - const argsPatterns: Array = - commandPrefixes.length > 0 - ? commandPrefixes.map( - (prefix) => `"command":"${escapeRegex(prefix)}(?:[\\s"]|$)`, - ) - : [effectiveArgsPattern]; + const argsPatterns = buildArgsPatterns( + rule.argsPattern, + rule.commandPrefix, + rule.commandRegex, + ); // For each argsPattern, expand toolName arrays return argsPatterns.flatMap((argsPattern) => { @@ -400,6 +376,7 @@ export async function loadPoliciesFromToml( decision: rule.decision, priority: transformPriority(rule.priority, tier), modes: tier === 1 ? rule.modes : undefined, + allowRedirection: rule.allow_redirection, }; // Compile regex pattern @@ -436,24 +413,11 @@ export async function loadPoliciesFromToml( validationResult.data.safety_checker ?? [] ) .flatMap((checker) => { - let effectiveArgsPattern = checker.argsPattern; - const commandPrefixes: string[] = []; - - if (checker.commandPrefix) { - const prefixes = Array.isArray(checker.commandPrefix) - ? checker.commandPrefix - : [checker.commandPrefix]; - commandPrefixes.push(...prefixes); - } else if (checker.commandRegex) { - effectiveArgsPattern = `"command":"${checker.commandRegex}`; - } - - const argsPatterns: Array = - commandPrefixes.length > 0 - ? commandPrefixes.map( - (prefix) => `"command":"${escapeRegex(prefix)}(?:[\\s"]|$)`, - ) - : [effectiveArgsPattern]; + const argsPatterns = buildArgsPatterns( + checker.argsPattern, + checker.commandPrefix, + checker.commandRegex, + ); return argsPatterns.flatMap((argsPattern) => { const toolNames: Array = checker.toolName diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 426fdaac9c..1d61ec84c5 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -123,6 +123,13 @@ export interface PolicyRule { * If undefined or empty, it applies to all modes. */ modes?: ApprovalMode[]; + + /** + * If true, allows command redirection even if the policy engine would normally + * downgrade ALLOW to ASK_USER for redirected commands. + * Only applies when decision is ALLOW. + */ + allowRedirection?: boolean; } export interface SafetyCheckerRule { diff --git a/packages/core/src/policy/utils.test.ts b/packages/core/src/policy/utils.test.ts new file mode 100644 index 0000000000..991cd28eed --- /dev/null +++ b/packages/core/src/policy/utils.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { escapeRegex, buildArgsPatterns } from './utils.js'; + +describe('policy/utils', () => { + describe('escapeRegex', () => { + it('should escape special regex characters', () => { + const input = '.-*+?^${}()|[]\\ "'; + const escaped = escapeRegex(input); + expect(escaped).toBe( + '\\.\\-\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\ \\"', + ); + }); + + it('should return the same string if no special characters are present', () => { + const input = 'abcABC123'; + expect(escapeRegex(input)).toBe(input); + }); + }); + + describe('buildArgsPatterns', () => { + it('should return argsPattern if provided and no commandPrefix/regex', () => { + const result = buildArgsPatterns('my-pattern', undefined, undefined); + expect(result).toEqual(['my-pattern']); + }); + + it('should build pattern from a single commandPrefix', () => { + const result = buildArgsPatterns(undefined, 'ls', undefined); + expect(result).toEqual(['"command":"ls(?:[\\s"]|$)']); + }); + + it('should build patterns from an array of commandPrefixes', () => { + const result = buildArgsPatterns(undefined, ['ls', 'cd'], undefined); + expect(result).toEqual([ + '"command":"ls(?:[\\s"]|$)', + '"command":"cd(?:[\\s"]|$)', + ]); + }); + + it('should build pattern from commandRegex', () => { + const result = buildArgsPatterns(undefined, undefined, 'rm -rf .*'); + expect(result).toEqual(['"command":"rm -rf .*']); + }); + + it('should prioritize commandPrefix over commandRegex and argsPattern', () => { + const result = buildArgsPatterns('raw', 'prefix', 'regex'); + expect(result).toEqual(['"command":"prefix(?:[\\s"]|$)']); + }); + + it('should prioritize commandRegex over argsPattern if no commandPrefix', () => { + const result = buildArgsPatterns('raw', undefined, 'regex'); + expect(result).toEqual(['"command":"regex']); + }); + + it('should escape characters in commandPrefix', () => { + const result = buildArgsPatterns(undefined, 'git checkout -b', undefined); + expect(result).toEqual(['"command":"git\\ checkout\\ \\-b(?:[\\s"]|$)']); + }); + + it('should correctly escape quotes in commandPrefix', () => { + const result = buildArgsPatterns(undefined, 'git "fix"', undefined); + expect(result).toEqual([ + '"command":"git\\ \\\\\\"fix\\\\\\"(?:[\\s"]|$)', + ]); + }); + + it('should handle undefined correctly when no inputs are provided', () => { + const result = buildArgsPatterns(undefined, undefined, undefined); + expect(result).toEqual([undefined]); + }); + }); +}); diff --git a/packages/core/src/policy/utils.ts b/packages/core/src/policy/utils.ts new file mode 100644 index 0000000000..0052e90035 --- /dev/null +++ b/packages/core/src/policy/utils.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Escapes a string for use in a regular expression. + */ +export function escapeRegex(text: string): string { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s"]/g, '\\$&'); +} + +/** + * Builds a list of args patterns for policy matching. + * + * This function handles the transformation of command prefixes and regexes into + * the internal argsPattern representation used by the PolicyEngine. + * + * @param argsPattern An optional raw regex string for arguments. + * @param commandPrefix An optional command prefix (or list of prefixes) to allow. + * @param commandRegex An optional command regex string to allow. + * @returns An array of string patterns (or undefined) for the PolicyEngine. + */ +export function buildArgsPatterns( + argsPattern?: string, + commandPrefix?: string | string[], + commandRegex?: string, +): Array { + if (commandPrefix) { + const prefixes = Array.isArray(commandPrefix) + ? commandPrefix + : [commandPrefix]; + + // Expand command prefixes to multiple patterns. + // We append [\\s"] to ensure we match whole words only (e.g., "git" but not + // "github"). Since we match against JSON stringified args, the value is + // always followed by a space or a closing quote. + return prefixes.map((prefix) => { + const jsonPrefix = JSON.stringify(prefix).slice(1, -1); + return `"command":"${escapeRegex(jsonPrefix)}(?:[\\s"]|$)`; + }); + } + + if (commandRegex) { + return [`"command":"${commandRegex}`]; + } + + return [argsPattern]; +} diff --git a/packages/core/src/services/skillManager.test.ts b/packages/core/src/services/skillManager.test.ts deleted file mode 100644 index a7118605ca..0000000000 --- a/packages/core/src/services/skillManager.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { SkillManager } from './skillManager.js'; -import { Storage } from '../config/storage.js'; - -describe('SkillManager', () => { - let testRootDir: string; - - beforeEach(async () => { - testRootDir = await fs.mkdtemp( - path.join(os.tmpdir(), 'skill-manager-test-'), - ); - }); - - afterEach(async () => { - await fs.rm(testRootDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it('should discover skills with valid SKILL.md and frontmatter', async () => { - const skillDir = path.join(testRootDir, 'my-skill'); - await fs.mkdir(skillDir, { recursive: true }); - const skillFile = path.join(skillDir, 'SKILL.md'); - await fs.writeFile( - skillFile, - `--- -name: my-skill -description: A test skill ---- -# Instructions -Do something. -`, - ); - - const service = new SkillManager(); - const skills = await service.discoverSkillsInternal([testRootDir]); - - expect(skills).toHaveLength(1); - expect(skills[0].name).toBe('my-skill'); - expect(skills[0].description).toBe('A test skill'); - expect(skills[0].location).toBe(skillFile); - expect(skills[0].body).toBe('# Instructions\nDo something.'); - }); - - it('should ignore directories without SKILL.md', async () => { - const notASkillDir = path.join(testRootDir, 'not-a-skill'); - await fs.mkdir(notASkillDir, { recursive: true }); - - const service = new SkillManager(); - const skills = await service.discoverSkillsInternal([testRootDir]); - - expect(skills).toHaveLength(0); - }); - - it('should ignore SKILL.md without valid frontmatter', async () => { - const skillDir = path.join(testRootDir, 'invalid-skill'); - await fs.mkdir(skillDir, { recursive: true }); - const skillFile = path.join(skillDir, 'SKILL.md'); - await fs.writeFile(skillFile, '# No frontmatter here'); - - const service = new SkillManager(); - const skills = await service.discoverSkillsInternal([testRootDir]); - - expect(skills).toHaveLength(0); - }); - - it('should ignore SKILL.md with missing required frontmatter fields', async () => { - const skillDir = path.join(testRootDir, 'missing-fields'); - await fs.mkdir(skillDir, { recursive: true }); - const skillFile = path.join(skillDir, 'SKILL.md'); - await fs.writeFile( - skillFile, - `--- -name: missing-fields ---- -`, - ); - - const service = new SkillManager(); - const skills = await service.discoverSkillsInternal([testRootDir]); - - expect(skills).toHaveLength(0); - }); - - it('should handle multiple search paths', async () => { - const path1 = path.join(testRootDir, 'path1'); - const path2 = path.join(testRootDir, 'path2'); - await fs.mkdir(path1, { recursive: true }); - await fs.mkdir(path2, { recursive: true }); - - const skill1Dir = path.join(path1, 'skill1'); - await fs.mkdir(skill1Dir, { recursive: true }); - await fs.writeFile( - path.join(skill1Dir, 'SKILL.md'), - `--- -name: skill1 -description: Skill 1 ---- -`, - ); - - const skill2Dir = path.join(path2, 'skill2'); - await fs.mkdir(skill2Dir, { recursive: true }); - await fs.writeFile( - path.join(skill2Dir, 'SKILL.md'), - `--- -name: skill2 -description: Skill 2 ---- -`, - ); - - const service = new SkillManager(); - const skills = await service.discoverSkillsInternal([path1, path2]); - - expect(skills).toHaveLength(2); - expect(skills.map((s) => s.name).sort()).toEqual(['skill1', 'skill2']); - }); - - it('should deduplicate skills by name (last wins)', async () => { - const path1 = path.join(testRootDir, 'path1'); - const path2 = path.join(testRootDir, 'path2'); - await fs.mkdir(path1, { recursive: true }); - await fs.mkdir(path2, { recursive: true }); - - await fs.mkdir(path.join(path1, 'skill'), { recursive: true }); - await fs.writeFile( - path.join(path1, 'skill', 'SKILL.md'), - `--- -name: same-name -description: First ---- -`, - ); - - await fs.mkdir(path.join(path2, 'skill'), { recursive: true }); - await fs.writeFile( - path.join(path2, 'skill', 'SKILL.md'), - `--- -name: same-name -description: Second ---- -`, - ); - - const service = new SkillManager(); - // In our tiered discovery logic, we call discoverSkillsInternal for each tier - // and then add them with precedence. - const skills1 = await service.discoverSkillsInternal([path1]); - service['addSkillsWithPrecedence'](skills1); - const skills2 = await service.discoverSkillsInternal([path2]); - service['addSkillsWithPrecedence'](skills2); - - const skills = service.getSkills(); - expect(skills).toHaveLength(1); - expect(skills[0].description).toBe('Second'); - }); - - it('should discover skills from Storage with project precedence', async () => { - const userDir = path.join(testRootDir, 'user'); - const projectDir = path.join(testRootDir, 'project'); - await fs.mkdir(path.join(userDir, 'skill-a'), { recursive: true }); - await fs.mkdir(path.join(projectDir, 'skill-a'), { recursive: true }); - - await fs.writeFile( - path.join(userDir, 'skill-a', 'SKILL.md'), - `--- -name: skill-a -description: user-desc ---- -`, - ); - await fs.writeFile( - path.join(projectDir, 'skill-a', 'SKILL.md'), - `--- -name: skill-a -description: project-desc ---- -`, - ); - - vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir); - const storage = new Storage('/dummy'); - vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir); - - const service = new SkillManager(); - await service.discoverSkills(storage); - - const skills = service.getSkills(); - expect(skills).toHaveLength(1); - expect(skills[0].description).toBe('project-desc'); - }); - - it('should filter disabled skills in getSkills but not in getAllSkills', async () => { - const skill1Dir = path.join(testRootDir, 'skill1'); - const skill2Dir = path.join(testRootDir, 'skill2'); - await fs.mkdir(skill1Dir, { recursive: true }); - await fs.mkdir(skill2Dir, { recursive: true }); - - await fs.writeFile( - path.join(skill1Dir, 'SKILL.md'), - `--- -name: skill1 -description: desc1 ---- -`, - ); - await fs.writeFile( - path.join(skill2Dir, 'SKILL.md'), - `--- -name: skill2 -description: desc2 ---- -`, - ); - - const service = new SkillManager(); - const discovered = await service.discoverSkillsInternal([testRootDir]); - service['addSkillsWithPrecedence'](discovered); - service.setDisabledSkills(['skill1']); - - expect(service.getSkills()).toHaveLength(1); - expect(service.getSkills()[0].name).toBe('skill2'); - expect(service.getAllSkills()).toHaveLength(2); - expect( - service.getAllSkills().find((s) => s.name === 'skill1')?.disabled, - ).toBe(true); - }); -}); diff --git a/packages/core/src/services/skillManager.ts b/packages/core/src/services/skillManager.ts deleted file mode 100644 index 846f705548..0000000000 --- a/packages/core/src/services/skillManager.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { glob } from 'glob'; -import yaml from 'js-yaml'; -import { debugLogger } from '../utils/debugLogger.js'; -import { Storage } from '../config/storage.js'; - -export interface SkillMetadata { - name: string; - description: string; - location: string; - body: string; - disabled?: boolean; -} - -const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/; - -export class SkillManager { - private skills: SkillMetadata[] = []; - private activeSkillNames: Set = new Set(); - - /** - * Clears all discovered skills. - */ - clearSkills(): void { - this.skills = []; - } - - /** - * Discovers skills from standard user and project locations. - * Project skills take precedence over user skills. - */ - async discoverSkills(storage: Storage): Promise { - this.clearSkills(); - - // User skills first - const userPaths = [Storage.getUserSkillsDir()]; - const userSkills = await this.discoverSkillsInternal(userPaths); - this.addSkillsWithPrecedence(userSkills); - - // Project skills second (overwrites user skills with same name) - const projectPaths = [storage.getProjectSkillsDir()]; - const projectSkills = await this.discoverSkillsInternal(projectPaths); - this.addSkillsWithPrecedence(projectSkills); - } - - private addSkillsWithPrecedence(newSkills: SkillMetadata[]): void { - const skillMap = new Map(); - for (const skill of [...this.skills, ...newSkills]) { - skillMap.set(skill.name, skill); - } - this.skills = Array.from(skillMap.values()); - } - - /** - * Discovered skills in the provided paths and adds them to the manager. - * Internal helper for tiered discovery. - */ - async discoverSkillsInternal(paths: string[]): Promise { - const discoveredSkills: SkillMetadata[] = []; - const seenLocations = new Set(this.skills.map((s) => s.location)); - - for (const searchPath of paths) { - try { - const absoluteSearchPath = path.resolve(searchPath); - debugLogger.debug(`Discovering skills in: ${absoluteSearchPath}`); - - const stats = await fs.stat(absoluteSearchPath).catch(() => null); - if (!stats || !stats.isDirectory()) { - debugLogger.debug( - `Search path is not a directory: ${absoluteSearchPath}`, - ); - continue; - } - - const skillFiles = await glob('*/SKILL.md', { - cwd: absoluteSearchPath, - absolute: true, - nodir: true, - }); - - debugLogger.debug( - `Found ${skillFiles.length} potential skill files in ${absoluteSearchPath}`, - ); - - for (const skillFile of skillFiles) { - if (seenLocations.has(skillFile)) { - continue; - } - - const metadata = await this.parseSkillFile(skillFile); - if (metadata) { - debugLogger.debug( - `Discovered skill: ${metadata.name} at ${skillFile}`, - ); - discoveredSkills.push(metadata); - seenLocations.add(skillFile); - } - } - } catch (error) { - debugLogger.log(`Error discovering skills in ${searchPath}:`, error); - } - } - - return discoveredSkills; - } - - /** - * Returns the list of enabled discovered skills. - */ - getSkills(): SkillMetadata[] { - return this.skills.filter((s) => !s.disabled); - } - - /** - * Returns all discovered skills, including disabled ones. - */ - getAllSkills(): SkillMetadata[] { - return this.skills; - } - - /** - * Filters discovered skills by name. - */ - filterSkills(predicate: (skill: SkillMetadata) => boolean): void { - this.skills = this.skills.filter(predicate); - } - - /** - * Sets the list of disabled skill names. - */ - setDisabledSkills(disabledNames: string[]): void { - for (const skill of this.skills) { - skill.disabled = disabledNames.includes(skill.name); - } - } - - /** - * Reads the full content (metadata + body) of a skill by name. - */ - getSkill(name: string): SkillMetadata | null { - return this.skills.find((s) => s.name === name) ?? null; - } - - /** - * Activates a skill by name. - */ - activateSkill(name: string): void { - this.activeSkillNames.add(name); - } - - /** - * Checks if a skill is active. - */ - isSkillActive(name: string): boolean { - return this.activeSkillNames.has(name); - } - - private async parseSkillFile( - filePath: string, - ): Promise { - try { - const content = await fs.readFile(filePath, 'utf-8'); - const match = content.match(FRONTMATTER_REGEX); - if (!match) { - return null; - } - - // Use yaml.load() which is safe in js-yaml v4. - const frontmatter = yaml.load(match[1]); - if (!frontmatter || typeof frontmatter !== 'object') { - return null; - } - - const { name, description } = frontmatter as Record; - if (typeof name !== 'string' || typeof description !== 'string') { - return null; - } - - return { - name, - description, - location: filePath, - body: match[2].trim(), - }; - } catch (error) { - debugLogger.log(`Error parsing skill file ${filePath}:`, error); - return null; - } - } -} diff --git a/packages/core/src/skills/skillLoader.test.ts b/packages/core/src/skills/skillLoader.test.ts new file mode 100644 index 0000000000..3a42253f9f --- /dev/null +++ b/packages/core/src/skills/skillLoader.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { loadSkillsFromDir } from './skillLoader.js'; +import { coreEvents } from '../utils/events.js'; + +describe('skillLoader', () => { + let testRootDir: string; + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'skill-loader-test-'), + ); + vi.spyOn(coreEvents, 'emitFeedback'); + }); + + afterEach(async () => { + await fs.rm(testRootDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should load skills from a directory with valid SKILL.md', async () => { + const skillDir = path.join(testRootDir, 'my-skill'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile( + skillFile, + `---\nname: my-skill\ndescription: A test skill\n---\n# Instructions\nDo something.\n`, + ); + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('my-skill'); + expect(skills[0].description).toBe('A test skill'); + expect(skills[0].location).toBe(skillFile); + expect(skills[0].body).toBe('# Instructions\nDo something.'); + expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); + }); + + it('should emit feedback when no valid skills are found in a non-empty directory', async () => { + const notASkillDir = path.join(testRootDir, 'not-a-skill'); + await fs.mkdir(notASkillDir, { recursive: true }); + await fs.writeFile(path.join(notASkillDir, 'some-file.txt'), 'hello'); + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(0); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('Failed to load skills from'), + ); + }); + + it('should ignore empty directories and not emit feedback', async () => { + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(0); + expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); + }); + + it('should ignore directories without SKILL.md', async () => { + const notASkillDir = path.join(testRootDir, 'not-a-skill'); + await fs.mkdir(notASkillDir, { recursive: true }); + + // With a subdirectory, even if empty, it might still trigger readdir + // But my current logic is if discoveredSkills.length === 0, then check readdir + // If readdir is empty, it's fine. + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(0); + // If notASkillDir is empty, no warning. + }); + + it('should ignore SKILL.md without valid frontmatter and emit warning if directory is not empty', async () => { + const skillDir = path.join(testRootDir, 'invalid-skill'); + await fs.mkdir(skillDir, { recursive: true }); + const skillFile = path.join(skillDir, 'SKILL.md'); + await fs.writeFile(skillFile, '# No frontmatter here'); + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(0); + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining('Failed to load skills from'), + ); + }); + + it('should return empty array for non-existent directory', async () => { + const skills = await loadSkillsFromDir('/non/existent/path'); + expect(skills).toEqual([]); + expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/skills/skillLoader.ts b/packages/core/src/skills/skillLoader.ts new file mode 100644 index 0000000000..e9de2db2f0 --- /dev/null +++ b/packages/core/src/skills/skillLoader.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import yaml from 'js-yaml'; +import { debugLogger } from '../utils/debugLogger.js'; +import { coreEvents } from '../utils/events.js'; + +/** + * Represents the definition of an Agent Skill. + */ +export interface SkillDefinition { + /** The unique name of the skill. */ + name: string; + /** A concise description of what the skill does. */ + description: string; + /** The absolute path to the skill's source file on disk. */ + location: string; + /** The core logic/instructions of the skill. */ + body: string; + /** Whether the skill is currently disabled. */ + disabled?: boolean; +} + +const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/; + +/** + * Discovers and loads all skills in the provided directory. + */ +export async function loadSkillsFromDir( + dir: string, +): Promise { + const discoveredSkills: SkillDefinition[] = []; + + try { + const absoluteSearchPath = path.resolve(dir); + const stats = await fs.stat(absoluteSearchPath).catch(() => null); + if (!stats || !stats.isDirectory()) { + return []; + } + + const skillFiles = await glob('*/SKILL.md', { + cwd: absoluteSearchPath, + absolute: true, + nodir: true, + }); + + for (const skillFile of skillFiles) { + const metadata = await loadSkillFromFile(skillFile); + if (metadata) { + discoveredSkills.push(metadata); + } + } + + if (discoveredSkills.length === 0) { + const files = await fs.readdir(absoluteSearchPath); + if (files.length > 0) { + coreEvents.emitFeedback( + 'warning', + `Failed to load skills from ${absoluteSearchPath}. The directory is not empty but no valid skills were discovered. Please ensure SKILL.md files are present in subdirectories and have valid frontmatter.`, + ); + } + } + } catch (error) { + coreEvents.emitFeedback( + 'warning', + `Error discovering skills in ${dir}:`, + error, + ); + } + + return discoveredSkills; +} + +/** + * Loads a single skill from a SKILL.md file. + */ +export async function loadSkillFromFile( + filePath: string, +): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + const match = content.match(FRONTMATTER_REGEX); + if (!match) { + return null; + } + + const frontmatter = yaml.load(match[1]); + if (!frontmatter || typeof frontmatter !== 'object') { + return null; + } + + const { name, description } = frontmatter as Record; + if (typeof name !== 'string' || typeof description !== 'string') { + return null; + } + + return { + name, + description, + location: filePath, + body: match[2].trim(), + }; + } catch (error) { + debugLogger.log(`Error parsing skill file ${filePath}:`, error); + return null; + } +} diff --git a/packages/core/src/skills/skillManager.test.ts b/packages/core/src/skills/skillManager.test.ts new file mode 100644 index 0000000000..4bd200e4d7 --- /dev/null +++ b/packages/core/src/skills/skillManager.test.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { SkillManager } from './skillManager.js'; +import { Storage } from '../config/storage.js'; +import { type GeminiCLIExtension } from '../config/config.js'; + +describe('SkillManager', () => { + let testRootDir: string; + + beforeEach(async () => { + testRootDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'skill-manager-test-'), + ); + }); + + afterEach(async () => { + await fs.rm(testRootDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should discover skills from extensions, user, and project with precedence', async () => { + const userDir = path.join(testRootDir, 'user'); + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(path.join(userDir, 'skill-a'), { recursive: true }); + await fs.mkdir(path.join(projectDir, 'skill-b'), { recursive: true }); + + await fs.writeFile( + path.join(userDir, 'skill-a', 'SKILL.md'), + `--- +name: skill-user +description: user-desc +--- +`, + ); + await fs.writeFile( + path.join(projectDir, 'skill-b', 'SKILL.md'), + `--- +name: skill-project +description: project-desc +--- +`, + ); + + const mockExtension: GeminiCLIExtension = { + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: '/ext', + contextFiles: [], + id: 'ext-id', + skills: [ + { + name: 'skill-extension', + description: 'ext-desc', + location: '/ext/skills/SKILL.md', + body: 'body', + }, + ], + }; + + vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir); + const storage = new Storage('/dummy'); + vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir); + + const service = new SkillManager(); + await service.discoverSkills(storage, [mockExtension]); + + const skills = service.getSkills(); + expect(skills).toHaveLength(3); + const names = skills.map((s) => s.name); + expect(names).toContain('skill-extension'); + expect(names).toContain('skill-user'); + expect(names).toContain('skill-project'); + }); + + it('should respect precedence: Project > User > Extension', async () => { + const userDir = path.join(testRootDir, 'user'); + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(path.join(userDir, 'skill'), { recursive: true }); + await fs.mkdir(path.join(projectDir, 'skill'), { recursive: true }); + + await fs.writeFile( + path.join(userDir, 'skill', 'SKILL.md'), + `--- +name: same-name +description: user-desc +--- +`, + ); + await fs.writeFile( + path.join(projectDir, 'skill', 'SKILL.md'), + `--- +name: same-name +description: project-desc +--- +`, + ); + + const mockExtension: GeminiCLIExtension = { + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: '/ext', + contextFiles: [], + id: 'ext-id', + skills: [ + { + name: 'same-name', + description: 'ext-desc', + location: '/ext/skills/SKILL.md', + body: 'body', + }, + ], + }; + + vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir); + const storage = new Storage('/dummy'); + vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir); + + const service = new SkillManager(); + await service.discoverSkills(storage, [mockExtension]); + + const skills = service.getSkills(); + expect(skills).toHaveLength(1); + expect(skills[0].description).toBe('project-desc'); + + // Test User > Extension + vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue('/non-existent'); + await service.discoverSkills(storage, [mockExtension]); + expect(service.getSkills()[0].description).toBe('user-desc'); + }); + + it('should filter disabled skills in getSkills but not in getAllSkills', async () => { + const skillDir = path.join(testRootDir, 'skill1'); + await fs.mkdir(skillDir, { recursive: true }); + + await fs.writeFile( + path.join(skillDir, 'SKILL.md'), + `--- +name: skill1 +description: desc1 +--- +`, + ); + + const storage = new Storage('/dummy'); + vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(testRootDir); + vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue('/non-existent'); + + const service = new SkillManager(); + await service.discoverSkills(storage); + service.setDisabledSkills(['skill1']); + + expect(service.getSkills()).toHaveLength(0); + expect(service.getAllSkills()).toHaveLength(1); + expect(service.getAllSkills()[0].disabled).toBe(true); + }); +}); diff --git a/packages/core/src/skills/skillManager.ts b/packages/core/src/skills/skillManager.ts new file mode 100644 index 0000000000..22e0858cc8 --- /dev/null +++ b/packages/core/src/skills/skillManager.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Storage } from '../config/storage.js'; +import { type SkillDefinition, loadSkillsFromDir } from './skillLoader.js'; +import type { GeminiCLIExtension } from '../config/config.js'; + +export { type SkillDefinition }; + +export class SkillManager { + private skills: SkillDefinition[] = []; + private activeSkillNames: Set = new Set(); + + /** + * Clears all discovered skills. + */ + clearSkills(): void { + this.skills = []; + } + + /** + * Discovers skills from standard user and project locations, as well as extensions. + * Precedence: Extensions (lowest) -> User -> Project (highest). + */ + async discoverSkills( + storage: Storage, + extensions: GeminiCLIExtension[] = [], + ): Promise { + this.clearSkills(); + + // 1. Extension skills (lowest precedence) + for (const extension of extensions) { + if (extension.isActive && extension.skills) { + this.addSkillsWithPrecedence(extension.skills); + } + } + + // 2. User skills + const userSkills = await loadSkillsFromDir(Storage.getUserSkillsDir()); + this.addSkillsWithPrecedence(userSkills); + + // 3. Project skills (highest precedence) + const projectSkills = await loadSkillsFromDir( + storage.getProjectSkillsDir(), + ); + this.addSkillsWithPrecedence(projectSkills); + } + + private addSkillsWithPrecedence(newSkills: SkillDefinition[]): void { + const skillMap = new Map(); + for (const skill of [...this.skills, ...newSkills]) { + skillMap.set(skill.name, skill); + } + this.skills = Array.from(skillMap.values()); + } + + /** + * Returns the list of enabled discovered skills. + */ + getSkills(): SkillDefinition[] { + return this.skills.filter((s) => !s.disabled); + } + + /** + * Returns all discovered skills, including disabled ones. + */ + getAllSkills(): SkillDefinition[] { + return this.skills; + } + + /** + * Filters discovered skills by name. + */ + filterSkills(predicate: (skill: SkillDefinition) => boolean): void { + this.skills = this.skills.filter(predicate); + } + + /** + * Sets the list of disabled skill names. + */ + setDisabledSkills(disabledNames: string[]): void { + for (const skill of this.skills) { + skill.disabled = disabledNames.includes(skill.name); + } + } + + /** + * Reads the full content (metadata + body) of a skill by name. + */ + getSkill(name: string): SkillDefinition | null { + return this.skills.find((s) => s.name === name) ?? null; + } + + /** + * Activates a skill by name. + */ + activateSkill(name: string): void { + this.activeSkillNames.add(name); + } + + /** + * Checks if a skill is active. + */ + isSkillActive(name: string): boolean { + return this.activeSkillNames.has(name); + } +} diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index 9a4c811774..c7cc10cdaa 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -316,7 +316,7 @@ describe('ClearcutLogger', () => { it('logs all user settings', () => { const { logger } = setup({ - config: { useSmartEdit: true }, + config: {}, }); vi.stubEnv('TERM_PROGRAM', 'vscode'); diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index bd0f387bc1..3dabc4a89d 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -17,6 +17,7 @@ import { ToolConfirmationOutcome, ToolErrorType, ToolRegistry, + type MessageBus, } from '../index.js'; import { OutputFormat } from '../output/types.js'; import { logs } from '@opentelemetry/api-logs'; @@ -94,6 +95,7 @@ import { import * as metrics from './metrics.js'; import { FileOperation } from './metrics.js'; import * as sdk from './sdk.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest'; import { type GeminiCLIExtension } from '../config/config.js'; import { @@ -999,7 +1001,8 @@ describe('loggers', () => { }, }), getQuestion: () => 'test-question', - getToolRegistry: () => new ToolRegistry(cfg1), + getToolRegistry: () => + new ToolRegistry(cfg1, {} as unknown as MessageBus), getUserMemory: () => 'user-memory', } as unknown as Config; @@ -1031,7 +1034,7 @@ describe('loggers', () => { }); it('should log a tool call with all fields', () => { - const tool = new EditTool(mockConfig); + const tool = new EditTool(mockConfig, createMockMessageBus()); const call: CompletedToolCall = { status: 'success', request: { @@ -1247,7 +1250,7 @@ describe('loggers', () => { contentLength: 13, }, outcome: ToolConfirmationOutcome.ModifyWithEditor, - tool: new EditTool(mockConfig), + tool: new EditTool(mockConfig, createMockMessageBus()), invocation: {} as AnyToolInvocation, durationMs: 100, }; @@ -1326,7 +1329,7 @@ describe('loggers', () => { errorType: undefined, contentLength: 13, }, - tool: new EditTool(mockConfig), + tool: new EditTool(mockConfig, createMockMessageBus()), invocation: {} as AnyToolInvocation, durationMs: 100, }; @@ -1478,6 +1481,7 @@ describe('loggers', () => { }, required: ['arg1', 'arg2'], }, + createMockMessageBus(), false, undefined, undefined, @@ -1748,7 +1752,6 @@ describe('loggers', () => { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, getContentGeneratorConfig: () => null, - getUseSmartEdit: () => null, isInteractive: () => false, } as unknown as Config; @@ -1799,7 +1802,6 @@ describe('loggers', () => { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, getContentGeneratorConfig: () => null, - getUseSmartEdit: () => null, isInteractive: () => false, } as unknown as Config; @@ -1852,7 +1854,6 @@ describe('loggers', () => { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, getContentGeneratorConfig: () => null, - getUseSmartEdit: () => null, isInteractive: () => false, } as unknown as Config; diff --git a/packages/core/src/test-utils/mock-message-bus.ts b/packages/core/src/test-utils/mock-message-bus.ts index 7c494c343b..1bd18c2f55 100644 --- a/packages/core/src/test-utils/mock-message-bus.ts +++ b/packages/core/src/test-utils/mock-message-bus.ts @@ -24,6 +24,7 @@ export class MockMessageBus { publishedMessages: Message[] = []; hookRequests: HookExecutionRequest[] = []; hookResponses: HookExecutionResponse[] = []; + defaultToolDecision: 'allow' | 'deny' | 'ask_user' = 'allow'; /** * Mock publish method that captures messages and simulates responses @@ -50,6 +51,34 @@ export class MockMessageBus { // Emit response to subscribers this.emit(MessageBusType.HOOK_EXECUTION_RESPONSE, response); } + + // Handle tool confirmation requests + if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) { + if (this.defaultToolDecision === 'allow') { + this.emit(MessageBusType.TOOL_CONFIRMATION_RESPONSE, { + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: message.correlationId, + confirmed: true, + }); + } else if (this.defaultToolDecision === 'deny') { + this.emit(MessageBusType.TOOL_CONFIRMATION_RESPONSE, { + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: message.correlationId, + confirmed: false, + }); + } else { + // ask_user + this.emit(MessageBusType.TOOL_CONFIRMATION_RESPONSE, { + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: message.correlationId, + confirmed: false, + requiresUserConfirmation: true, + }); + } + } + + // Emit the message to subscribers (mimicking real MessageBus behavior) + this.emit(message.type, message); }); /** diff --git a/packages/core/src/test-utils/mock-tool.ts b/packages/core/src/test-utils/mock-tool.ts index cdfc649d46..2c12aa0962 100644 --- a/packages/core/src/test-utils/mock-tool.ts +++ b/packages/core/src/test-utils/mock-tool.ts @@ -18,6 +18,8 @@ import { BaseToolInvocation, Kind, } from '../tools/tools.js'; +import { createMockMessageBus } from './mock-message-bus.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; interface MockToolOptions { name: string; @@ -35,6 +37,7 @@ interface MockToolOptions { updateOutput?: (output: string) => void, ) => Promise; params?: object; + messageBus?: MessageBus; } class MockToolInvocation extends BaseToolInvocation< @@ -44,8 +47,9 @@ class MockToolInvocation extends BaseToolInvocation< constructor( private readonly tool: MockTool, params: { [key: string]: unknown }, + messageBus: MessageBus, ) { - super(params); + super(params, messageBus, tool.name, tool.displayName); } execute( @@ -94,6 +98,7 @@ export class MockTool extends BaseDeclarativeTool< options.description ?? options.name, Kind.Other, options.params, + options.messageBus ?? createMockMessageBus(), options.isOutputMarkdown ?? false, options.canUpdateOutput ?? false, ); @@ -115,10 +120,13 @@ export class MockTool extends BaseDeclarativeTool< } } - protected createInvocation(params: { - [key: string]: unknown; - }): ToolInvocation<{ [key: string]: unknown }, ToolResult> { - return new MockToolInvocation(this, params); + protected createInvocation( + params: { [key: string]: unknown }, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ): ToolInvocation<{ [key: string]: unknown }, ToolResult> { + return new MockToolInvocation(this, params, messageBus); } } @@ -138,8 +146,9 @@ export class MockModifiableToolInvocation extends BaseToolInvocation< constructor( private readonly tool: MockModifiableTool, params: Record, + messageBus: MessageBus, ) { - super(params); + super(params, messageBus, tool.name, tool.displayName); } async execute(_abortSignal: AbortSignal): Promise { @@ -189,10 +198,19 @@ export class MockModifiableTool shouldConfirm = true; constructor(name = 'mockModifiableTool') { - super(name, name, 'A mock modifiable tool for testing.', Kind.Other, { - type: 'object', - properties: { param: { type: 'string' } }, - }); + super( + name, + name, + 'A mock modifiable tool for testing.', + Kind.Other, + { + type: 'object', + properties: { param: { type: 'string' } }, + }, + createMockMessageBus(), + true, + false, + ); } getModifyContext( @@ -212,7 +230,10 @@ export class MockModifiableTool protected createInvocation( params: Record, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ): ToolInvocation, ToolResult> { - return new MockModifiableToolInvocation(this, params); + return new MockModifiableToolInvocation(this, params, messageBus); } } diff --git a/packages/core/src/tools/activate-skill.test.ts b/packages/core/src/tools/activate-skill.test.ts new file mode 100644 index 0000000000..3e7fe4a6e8 --- /dev/null +++ b/packages/core/src/tools/activate-skill.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ActivateSkillTool } from './activate-skill.js'; +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; + +vi.mock('../utils/getFolderStructure.js', () => ({ + getFolderStructure: vi.fn().mockResolvedValue('Mock folder structure'), +})); + +describe('ActivateSkillTool', () => { + let mockConfig: Config; + let tool: ActivateSkillTool; + let mockMessageBus: MessageBus; + + beforeEach(() => { + mockMessageBus = createMockMessageBus(); + const skills = [ + { + name: 'test-skill', + description: 'A test skill', + location: '/path/to/test-skill/SKILL.md', + }, + ]; + mockConfig = { + getSkillManager: vi.fn().mockReturnValue({ + getSkills: vi.fn().mockReturnValue(skills), + getAllSkills: vi.fn().mockReturnValue(skills), + getSkill: vi.fn().mockImplementation((name: string) => { + if (name === 'test-skill') { + return { + name: 'test-skill', + description: 'A test skill', + location: '/path/to/test-skill/SKILL.md', + body: 'Skill instructions content.', + }; + } + return null; + }), + activateSkill: vi.fn(), + }), + } as unknown as Config; + tool = new ActivateSkillTool(mockConfig, mockMessageBus); + }); + + it('should return enhanced description', () => { + const params = { name: 'test-skill' }; + const invocation = tool.build(params); + expect(invocation.getDescription()).toBe('"test-skill": A test skill'); + }); + + it('should return enhanced confirmation details', async () => { + const params = { name: 'test-skill' }; + const invocation = tool.build(params); + const details = await ( + invocation as unknown as { + getConfirmationDetails: (signal: AbortSignal) => Promise<{ + prompt: string; + title: string; + }>; + } + ).getConfirmationDetails(new AbortController().signal); + + expect(details.title).toBe('Activate Skill: test-skill'); + expect(details.prompt).toContain('enable the specialized agent skill'); + expect(details.prompt).toContain('A test skill'); + expect(details.prompt).toContain('Mock folder structure'); + }); + + it('should activate a valid skill and return its content in XML tags', async () => { + const params = { name: 'test-skill' }; + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(mockConfig.getSkillManager().activateSkill).toHaveBeenCalledWith( + 'test-skill', + ); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain('Skill instructions content.'); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain('Mock folder structure'); + expect(result.llmContent).toContain(''); + expect(result.llmContent).toContain(''); + expect(result.returnDisplay).toContain('Skill **test-skill** activated'); + expect(result.returnDisplay).toContain('Mock folder structure'); + }); + + it('should throw error if skill is not in enum', async () => { + const params = { name: 'non-existent' }; + expect(() => tool.build(params as { name: string })).toThrow(); + }); + + it('should return an error if skill content cannot be read', async () => { + vi.mocked(mockConfig.getSkillManager().getSkill).mockReturnValue(null); + const params = { name: 'test-skill' }; + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Error: Skill "test-skill" not found.'); + expect(mockConfig.getSkillManager().activateSkill).not.toHaveBeenCalled(); + }); + + it('should validate that name is provided', () => { + expect(() => + tool.build({ name: '' } as unknown as { name: string }), + ).toThrow(); + }); +}); diff --git a/packages/core/src/tools/activate-skill.ts b/packages/core/src/tools/activate-skill.ts new file mode 100644 index 0000000000..31ee4d0c24 --- /dev/null +++ b/packages/core/src/tools/activate-skill.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import * as path from 'node:path'; +import { getFolderStructure } from '../utils/getFolderStructure.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import type { + ToolResult, + ToolCallConfirmationDetails, + ToolInvocation, + ToolConfirmationOutcome, +} from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; +import type { Config } from '../config/config.js'; +import { ACTIVATE_SKILL_TOOL_NAME } from './tool-names.js'; + +/** + * Parameters for the ActivateSkill tool + */ +export interface ActivateSkillToolParams { + /** + * The name of the skill to activate + */ + name: string; +} + +class ActivateSkillToolInvocation extends BaseToolInvocation< + ActivateSkillToolParams, + ToolResult +> { + private cachedFolderStructure: string | undefined; + + constructor( + private config: Config, + params: ActivateSkillToolParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ) { + super(params, messageBus, _toolName, _toolDisplayName); + } + + getDescription(): string { + const skillName = this.params.name; + const skill = this.config.getSkillManager().getSkill(skillName); + if (skill) { + return `"${skillName}": ${skill.description}`; + } + return `"${skillName}" (⚠️ unknown skill)`; + } + + private async getOrFetchFolderStructure( + skillLocation: string, + ): Promise { + if (this.cachedFolderStructure === undefined) { + this.cachedFolderStructure = await getFolderStructure( + path.dirname(skillLocation), + ); + } + return this.cachedFolderStructure; + } + + protected override async getConfirmationDetails( + _abortSignal: AbortSignal, + ): Promise { + if (!this.messageBus) { + return false; + } + + const skillName = this.params.name; + const skill = this.config.getSkillManager().getSkill(skillName); + + if (!skill) { + return false; + } + + const folderStructure = await this.getOrFetchFolderStructure( + skill.location, + ); + + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'info', + title: `Activate Skill: ${skillName}`, + prompt: `You are about to enable the specialized agent skill **${skillName}**. + +**Description:** +${skill.description} + +**Resources to be shared with the model:** +${folderStructure}`, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + await this.publishPolicyUpdate(outcome); + }, + }; + return confirmationDetails; + } + + async execute(_signal: AbortSignal): Promise { + const skillName = this.params.name; + const skillManager = this.config.getSkillManager(); + const skill = skillManager.getSkill(skillName); + + if (!skill) { + const skills = skillManager.getSkills(); + return { + llmContent: `Error: Skill "${skillName}" not found. Available skills are: ${skills.map((s) => s.name).join(', ')}`, + returnDisplay: `Skill "${skillName}" not found.`, + }; + } + + skillManager.activateSkill(skillName); + + const folderStructure = await this.getOrFetchFolderStructure( + skill.location, + ); + + return { + llmContent: ` + + ${skill.body} + + + + ${folderStructure} + +`, + returnDisplay: `Skill **${skillName}** activated. Resources loaded from \`${path.dirname(skill.location)}\`:\n\n${folderStructure}`, + }; + } +} + +/** + * Implementation of the ActivateSkill tool logic + */ +export class ActivateSkillTool extends BaseDeclarativeTool< + ActivateSkillToolParams, + ToolResult +> { + static readonly Name = ACTIVATE_SKILL_TOOL_NAME; + + constructor( + private config: Config, + messageBus: MessageBus, + ) { + const skills = config.getSkillManager().getSkills(); + const skillNames = skills.map((s) => s.name); + + let schema: z.ZodTypeAny; + if (skillNames.length === 0) { + schema = z.object({ + name: z.string().describe('No skills are currently available.'), + }); + } else { + schema = z.object({ + name: z + .enum(skillNames as [string, ...string[]]) + .describe('The name of the skill to activate.'), + }); + } + + super( + ActivateSkillTool.Name, + 'Activate Skill', + "Activates a specialized agent skill by name. Returns the skill's instructions wrapped in `` tags. These provide specialized guidance for the current task. Use this when you identify a task that matches a skill's description.", + Kind.Other, + zodToJsonSchema(schema), + messageBus, + true, + false, + ); + } + + protected createInvocation( + params: ActivateSkillToolParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ): ToolInvocation { + return new ActivateSkillToolInvocation( + this.config, + params, + messageBus, + _toolName, + _toolDisplayName ?? 'Activate Skill', + ); + } +} diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 52e3d8702a..ca1505a2c4 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -59,6 +59,10 @@ import { ApprovalMode } from '../policy/types.js'; import type { Content, Part, SchemaUnion } from '@google/genai'; import { StandardFileSystemService } from '../services/fileSystemService.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { + createMockMessageBus, + getMockMessageBusInstance, +} from '../test-utils/mock-message-bus.js'; describe('EditTool', () => { let tool: EditTool; @@ -177,7 +181,9 @@ describe('EditTool', () => { }, ); - tool = new EditTool(mockConfig); + const bus = createMockMessageBus(); + getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; + tool = new EditTool(mockConfig, bus); }); afterEach(() => { @@ -1029,7 +1035,10 @@ describe('EditTool', () => { it('should use windows-style path examples on windows', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); - const tool = new EditTool({} as unknown as Config); + const tool = new EditTool( + {} as unknown as Config, + createMockMessageBus(), + ); const schema = tool.schema; expect( (schema.parametersJsonSchema as EditFileParameterSchema).properties @@ -1040,7 +1049,10 @@ describe('EditTool', () => { it('should use unix-style path examples on non-windows platforms', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); - const tool = new EditTool({} as unknown as Config); + const tool = new EditTool( + {} as unknown as Config, + createMockMessageBus(), + ); const schema = tool.schema; expect( (schema.parametersJsonSchema as EditFileParameterSchema).properties diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 475e8f2745..838bbc6c6e 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -118,7 +118,7 @@ class EditToolInvocation constructor( private readonly config: Config, params: EditToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, toolName?: string, displayName?: string, ) { @@ -492,7 +492,7 @@ export class EditTool constructor( private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( EditTool.Name, @@ -535,9 +535,9 @@ Expectation for required parameters: required: ['file_path', 'old_string', 'new_string'], type: 'object', }, + messageBus, true, // isOutputMarkdown false, // canUpdateOutput - messageBus, ); } @@ -568,14 +568,14 @@ Expectation for required parameters: protected createInvocation( params: EditToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, toolName?: string, displayName?: string, ): ToolInvocation { return new EditToolInvocation( this.config, params, - messageBus ?? this.messageBus, + messageBus, toolName ?? this.name, displayName ?? this.displayName, ); diff --git a/packages/core/src/tools/get-internal-docs.test.ts b/packages/core/src/tools/get-internal-docs.test.ts index 40a47b6477..bee9265e70 100644 --- a/packages/core/src/tools/get-internal-docs.test.ts +++ b/packages/core/src/tools/get-internal-docs.test.ts @@ -9,13 +9,14 @@ import { GetInternalDocsTool } from './get-internal-docs.js'; import { ToolErrorType } from './tool-error.js'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; describe('GetInternalDocsTool (Integration)', () => { let tool: GetInternalDocsTool; const abortSignal = new AbortController().signal; beforeEach(() => { - tool = new GetInternalDocsTool(); + tool = new GetInternalDocsTool(createMockMessageBus()); }); it('should find the documentation root and list files', async () => { diff --git a/packages/core/src/tools/get-internal-docs.ts b/packages/core/src/tools/get-internal-docs.ts index aec44a5272..c18c155404 100644 --- a/packages/core/src/tools/get-internal-docs.ts +++ b/packages/core/src/tools/get-internal-docs.ts @@ -80,8 +80,13 @@ class GetInternalDocsInvocation extends BaseToolInvocation< GetInternalDocsParams, ToolResult > { - constructor(params: GetInternalDocsParams, messageBus?: MessageBus) { - super(params, messageBus, GET_INTERNAL_DOCS_TOOL_NAME); + constructor( + params: GetInternalDocsParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ) { + super(params, messageBus, _toolName, _toolDisplayName); } override async shouldConfirmExecute( @@ -156,7 +161,7 @@ export class GetInternalDocsTool extends BaseDeclarativeTool< > { static readonly Name = GET_INTERNAL_DOCS_TOOL_NAME; - constructor(messageBus?: MessageBus) { + constructor(messageBus: MessageBus) { super( GetInternalDocsTool.Name, 'GetInternalDocs', @@ -172,16 +177,23 @@ export class GetInternalDocsTool extends BaseDeclarativeTool< }, }, }, + messageBus, /* isOutputMarkdown */ true, /* canUpdateOutput */ false, - messageBus, ); } protected createInvocation( params: GetInternalDocsParams, - messageBus?: MessageBus, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ): ToolInvocation { - return new GetInternalDocsInvocation(params, messageBus); + return new GetInternalDocsInvocation( + params, + messageBus, + _toolName ?? GetInternalDocsTool.Name, + _toolDisplayName, + ); } } diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index 4c65480089..381a47dc12 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -16,6 +16,7 @@ import type { Config } from '../config/config.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { ToolErrorType } from './tool-error.js'; import * as glob from 'glob'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; vi.mock('glob', { spy: true }); @@ -43,7 +44,7 @@ describe('GlobTool', () => { // Create a unique root directory for each test run tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'glob-tool-root-')); await fs.writeFile(path.join(tempRootDir, '.git'), ''); // Fake git repo - globTool = new GlobTool(mockConfig); + globTool = new GlobTool(mockConfig, createMockMessageBus()); // Create some test files and directories within this root // Top-level files diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index e505d3bf25..7a98d8e3e2 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -91,7 +91,7 @@ class GlobToolInvocation extends BaseToolInvocation< constructor( private config: Config, params: GlobToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -262,7 +262,7 @@ export class GlobTool extends BaseDeclarativeTool { static readonly Name = GLOB_TOOL_NAME; constructor( private config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( GlobTool.Name, @@ -300,9 +300,9 @@ export class GlobTool extends BaseDeclarativeTool { required: ['pattern'], type: 'object', }, + messageBus, true, false, - messageBus, ); } @@ -348,7 +348,7 @@ export class GlobTool extends BaseDeclarativeTool { protected createInvocation( params: GlobToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index ded3d0d311..131042fe3b 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -14,6 +14,7 @@ import type { Config } from '../config/config.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { ToolErrorType } from './tool-error.js'; import * as glob from 'glob'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; vi.mock('glob', { spy: true }); @@ -47,7 +48,7 @@ describe('GrepTool', () => { beforeEach(async () => { tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); - grepTool = new GrepTool(mockConfig); + grepTool = new GrepTool(mockConfig, createMockMessageBus()); // Create some test files and directories await fs.writeFile( @@ -270,7 +271,10 @@ describe('GrepTool', () => { }), } as unknown as Config; - const multiDirGrepTool = new GrepTool(multiDirConfig); + const multiDirGrepTool = new GrepTool( + multiDirConfig, + createMockMessageBus(), + ); const params: GrepToolParams = { pattern: 'world' }; const invocation = multiDirGrepTool.build(params); const result = await invocation.execute(abortSignal); @@ -323,7 +327,10 @@ describe('GrepTool', () => { }), } as unknown as Config; - const multiDirGrepTool = new GrepTool(multiDirConfig); + const multiDirGrepTool = new GrepTool( + multiDirConfig, + createMockMessageBus(), + ); // Search only in the 'sub' directory of the first workspace const params: GrepToolParams = { pattern: 'world', dir_path: 'sub' }; @@ -385,7 +392,10 @@ describe('GrepTool', () => { }), } as unknown as Config; - const multiDirGrepTool = new GrepTool(multiDirConfig); + const multiDirGrepTool = new GrepTool( + multiDirConfig, + createMockMessageBus(), + ); const params: GrepToolParams = { pattern: 'testPattern' }; const invocation = multiDirGrepTool.build(params); expect(invocation.getDescription()).toBe( diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 0df4dc5ec4..3fbbb141d6 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -62,7 +62,7 @@ class GrepToolInvocation extends BaseToolInvocation< constructor( private readonly config: Config, params: GrepToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -571,7 +571,7 @@ export class GrepTool extends BaseDeclarativeTool { static readonly Name = GREP_TOOL_NAME; constructor( private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( GrepTool.Name, @@ -599,9 +599,9 @@ export class GrepTool extends BaseDeclarativeTool { required: ['pattern'], type: 'object', }, + messageBus, true, false, - messageBus, ); } @@ -674,7 +674,7 @@ export class GrepTool extends BaseDeclarativeTool { protected createInvocation( params: GrepToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index 63a84aea43..06e8da264e 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -13,6 +13,7 @@ import type { Config } from '../config/config.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { ToolErrorType } from './tool-error.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; describe('LSTool', () => { let lsTool: LSTool; @@ -39,7 +40,7 @@ describe('LSTool', () => { }), } as unknown as Config; - lsTool = new LSTool(mockConfig); + lsTool = new LSTool(mockConfig, createMockMessageBus()); }); afterEach(async () => { diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 34a78038f4..80a5ecbc0d 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -73,7 +73,7 @@ class LSToolInvocation extends BaseToolInvocation { constructor( private readonly config: Config, params: LSToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -259,7 +259,7 @@ export class LSTool extends BaseDeclarativeTool { constructor( private config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( LSTool.Name, @@ -300,9 +300,9 @@ export class LSTool extends BaseDeclarativeTool { required: ['dir_path'], type: 'object', }, + messageBus, true, false, - messageBus, ); } @@ -330,14 +330,14 @@ export class LSTool extends BaseDeclarativeTool { protected createInvocation( params: LSToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { return new LSToolInvocation( this.config, params, - messageBus, + messageBus ?? this.messageBus, _toolName, _toolDisplayName, ); diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index 0ff201e8e6..d2fdc5d119 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -238,4 +238,40 @@ describe('McpClientManager', () => { ); }); }); + + describe('Promise rejection handling', () => { + it('should handle errors thrown during client initialization', async () => { + vi.mocked(McpClient).mockImplementation(() => { + throw new Error('Client initialization failed'); + }); + + mockConfig.getMcpServers.mockReturnValue({ + 'test-server': {}, + }); + + const manager = new McpClientManager({} as ToolRegistry, mockConfig); + + await expect(manager.startConfiguredMcpServers()).resolves.not.toThrow(); + }); + + it('should handle errors thrown in the async IIFE before try block', async () => { + let disconnectCallCount = 0; + mockedMcpClient.disconnect.mockImplementation(async () => { + disconnectCallCount++; + if (disconnectCallCount === 1) { + throw new Error('Disconnect failed unexpectedly'); + } + }); + mockedMcpClient.getServerConfig.mockReturnValue({}); + + mockConfig.getMcpServers.mockReturnValue({ + 'test-server': {}, + }); + + const manager = new McpClientManager({} as ToolRegistry, mockConfig); + await manager.startConfiguredMcpServers(); + + await expect(manager.restartServer('test-server')).resolves.not.toThrow(); + }); + }); }); diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index fbc3b3e423..7a1443e096 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -163,8 +163,7 @@ export class McpClientManager { return; } - const currentDiscoveryPromise = new Promise((resolve, _reject) => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises + const currentDiscoveryPromise = new Promise((resolve, reject) => { (async () => { try { if (existing) { @@ -212,6 +211,13 @@ export class McpClientManager { ); } } + } catch (error) { + const errorMessage = getErrorMessage(error); + coreEvents.emitFeedback( + 'error', + `Error initializing MCP server '${name}': ${errorMessage}`, + error, + ); } finally { // This is required to update the content generator configuration with the // new tool configuration. @@ -221,28 +227,30 @@ export class McpClientManager { } resolve(); } - })(); + })().catch(reject); }); if (this.discoveryPromise) { - this.discoveryPromise = this.discoveryPromise.then( - () => currentDiscoveryPromise, - ); + // Ensure the next discovery starts regardless of the previous one's success/failure + this.discoveryPromise = this.discoveryPromise + .catch(() => {}) + .then(() => currentDiscoveryPromise); } else { this.discoveryState = MCPDiscoveryState.IN_PROGRESS; this.discoveryPromise = currentDiscoveryPromise; } this.eventEmitter?.emit('mcp-client-update', this.clients); const currentPromise = this.discoveryPromise; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - currentPromise.then((_) => { - // If we are the last recorded discoveryPromise, then we are done, reset - // the world. - if (currentPromise === this.discoveryPromise) { - this.discoveryPromise = undefined; - this.discoveryState = MCPDiscoveryState.COMPLETED; - } - }); + void currentPromise + .finally(() => { + // If we are the last recorded discoveryPromise, then we are done, reset + // the world. + if (currentPromise === this.discoveryPromise) { + this.discoveryPromise = undefined; + this.discoveryState = MCPDiscoveryState.COMPLETED; + } + }) + .catch(() => {}); // Prevents unhandled rejection from the .finally branch return currentPromise; } diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index db7e102c89..1f96d34169 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -57,7 +57,7 @@ import type { } from '../utils/workspaceContext.js'; import type { ToolRegistry } from './tool-registry.js'; import { debugLogger } from '../utils/debugLogger.js'; -import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { type MessageBus } from '../confirmation-bus/message-bus.js'; import { coreEvents } from '../utils/events.js'; import type { ResourceRegistry } from '../resources/resource-registry.js'; import { @@ -895,7 +895,7 @@ export async function discoverTools( mcpServerConfig: MCPServerConfig, mcpClient: Client, cliConfig: Config, - messageBus?: MessageBus, + messageBus: MessageBus, options?: { timeout?: number; signal?: AbortSignal }, ): Promise { try { @@ -922,12 +922,12 @@ export async function discoverTools( toolDef.name, toolDef.description ?? '', toolDef.inputSchema ?? { type: 'object', properties: {} }, + messageBus, mcpServerConfig.trust, undefined, cliConfig, mcpServerConfig.extension?.name, mcpServerConfig.extension?.id, - messageBus, ); discoveredTools.push(tool); diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index fbd0d25dc1..5abc5779e9 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -13,6 +13,10 @@ import type { ToolResult } from './tools.js'; import { ToolConfirmationOutcome } from './tools.js'; // Added ToolConfirmationOutcome import type { CallableTool, Part } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; +import { + createMockMessageBus, + getMockMessageBusInstance, +} from '../test-utils/mock-message-bus.js'; // Mock @google/genai mcpToTool and CallableTool // We only need to mock the parts of CallableTool that DiscoveredMCPTool uses. @@ -85,12 +89,15 @@ describe('DiscoveredMCPTool', () => { beforeEach(() => { mockCallTool.mockClear(); mockToolMethod.mockClear(); + const bus = createMockMessageBus(); + getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; tool = new DiscoveredMCPTool( mockCallableToolInstance, serverName, serverToolName, baseDescription, inputSchema, + bus, ); // Clear allowlist before each relevant test, especially for shouldConfirmExecute const invocation = tool.build({ param: 'mock' }) as any; @@ -190,6 +197,12 @@ describe('DiscoveredMCPTool', () => { serverToolName, baseDescription, inputSchema, + createMockMessageBus(), + undefined, + undefined, + undefined, + undefined, + undefined, ); const params = { param: 'isErrorTrueCase' }; const functionCall = { @@ -230,6 +243,12 @@ describe('DiscoveredMCPTool', () => { serverToolName, baseDescription, inputSchema, + createMockMessageBus(), + undefined, + undefined, + undefined, + undefined, + undefined, ); const params = { param: 'isErrorTopLevelCase' }; const functionCall = { @@ -273,6 +292,12 @@ describe('DiscoveredMCPTool', () => { serverToolName, baseDescription, inputSchema, + createMockMessageBus(), + undefined, + undefined, + undefined, + undefined, + undefined, ); const params = { param: 'isErrorFalseCase' }; const mockToolSuccessResultObject = { @@ -728,9 +753,12 @@ describe('DiscoveredMCPTool', () => { serverToolName, baseDescription, inputSchema, + createMockMessageBus(), true, undefined, { isTrustedFolder: () => true } as any, + undefined, + undefined, ); const invocation = trustedTool.build({ param: 'mock' }); expect( @@ -862,15 +890,20 @@ describe('DiscoveredMCPTool', () => { 'return confirmation details if trust is false, even if folder is trusted', }, ])('should $description', async ({ trust, isTrusted, shouldConfirm }) => { + const bus = createMockMessageBus(); + getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; const testTool = new DiscoveredMCPTool( mockCallableToolInstance, serverName, serverToolName, baseDescription, inputSchema, + bus, trust, undefined, mockConfig(isTrusted) as any, + undefined, + undefined, ); const invocation = testTool.build({ param: 'mock' }); const confirmation = await invocation.shouldConfirmExecute( diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 280927a6e0..44a07d99e8 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -70,10 +70,10 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< readonly serverName: string, readonly serverToolName: string, readonly displayName: string, + messageBus: MessageBus, readonly trust?: boolean, params: ToolParams = {}, private readonly cliConfig?: Config, - messageBus?: MessageBus, ) { // Use composite format for policy checks: serverName__toolName // This enables server wildcards (e.g., "google-workspace__*") @@ -239,12 +239,12 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< readonly serverToolName: string, description: string, override readonly parameterSchema: unknown, + messageBus: MessageBus, readonly trust?: boolean, nameOverride?: string, private readonly cliConfig?: Config, override readonly extensionName?: string, override readonly extensionId?: string, - messageBus?: MessageBus, ) { super( nameOverride ?? generateValidName(serverToolName), @@ -252,9 +252,9 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< description, Kind.Other, parameterSchema, + messageBus, true, // isOutputMarkdown false, // canUpdateOutput, - messageBus, extensionName, extensionId, ); @@ -271,18 +271,18 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.serverToolName, this.description, this.parameterSchema, + this.messageBus, this.trust, `${this.getFullyQualifiedPrefix()}${this.serverToolName}`, this.cliConfig, this.extensionName, this.extensionId, - this.messageBus, ); } protected createInvocation( params: ToolParams, - _messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _displayName?: string, ): ToolInvocation { @@ -290,11 +290,11 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.mcpTool, this.serverName, this.serverToolName, - this.displayName, + _displayName ?? this.displayName, + messageBus, this.trust, params, this.cliConfig, - _messageBus, ); } } diff --git a/packages/core/src/tools/memoryTool.test.ts b/packages/core/src/tools/memoryTool.test.ts index fb251d37ec..4581b19232 100644 --- a/packages/core/src/tools/memoryTool.test.ts +++ b/packages/core/src/tools/memoryTool.test.ts @@ -19,6 +19,10 @@ import * as os from 'node:os'; import { ToolConfirmationOutcome } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import { GEMINI_DIR } from '../utils/paths.js'; +import { + createMockMessageBus, + getMockMessageBusInstance, +} from '../test-utils/mock-message-bus.js'; // Mock dependencies vi.mock(import('node:fs/promises'), async (importOriginal) => { @@ -200,7 +204,7 @@ describe('MemoryTool', () => { let performAddMemoryEntrySpy: Mock; beforeEach(() => { - memoryTool = new MemoryTool(); + memoryTool = new MemoryTool(createMockMessageBus()); // Spy on the static method for these tests performAddMemoryEntrySpy = vi .spyOn(MemoryTool, 'performAddMemoryEntry') @@ -300,7 +304,9 @@ describe('MemoryTool', () => { let memoryTool: MemoryTool; beforeEach(() => { - memoryTool = new MemoryTool(); + const bus = createMockMessageBus(); + getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; + memoryTool = new MemoryTool(bus); // Clear the allowlist before each test const invocation = memoryTool.build({ fact: 'mock-fact' }); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 3e38d6d294..56de14eae7 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -179,7 +179,7 @@ class MemoryToolInvocation extends BaseToolInvocation< constructor( params: SaveMemoryParams, - messageBus?: MessageBus, + messageBus: MessageBus, toolName?: string, displayName?: string, ) { @@ -298,16 +298,16 @@ export class MemoryTool { static readonly Name = MEMORY_TOOL_NAME; - constructor(messageBus?: MessageBus) { + constructor(messageBus: MessageBus) { super( MemoryTool.Name, 'SaveMemory', memoryToolDescription, Kind.Think, memoryToolSchemaData.parametersJsonSchema as Record, + messageBus, true, false, - messageBus, ); } @@ -323,13 +323,13 @@ export class MemoryTool protected createInvocation( params: SaveMemoryParams, - messageBus?: MessageBus, + messageBus: MessageBus, toolName?: string, displayName?: string, ) { return new MemoryToolInvocation( params, - messageBus ?? this.messageBus, + messageBus, toolName ?? this.name, displayName ?? this.displayName, ); diff --git a/packages/core/src/tools/message-bus-integration.test.ts b/packages/core/src/tools/message-bus-integration.test.ts index 2ee38b0d22..bfc369b58b 100644 --- a/packages/core/src/tools/message-bus-integration.test.ts +++ b/packages/core/src/tools/message-bus-integration.test.ts @@ -56,22 +56,19 @@ class TestToolInvocation extends BaseToolInvocation { override async shouldConfirmExecute( abortSignal: AbortSignal, ): Promise { - // This conditional is here to allow testing of the case where there is no message bus. - if (this.messageBus) { - const decision = await this.getMessageBusDecision(abortSignal); - if (decision === 'ALLOW') { - return false; - } - if (decision === 'DENY') { - throw new Error('Tool execution denied by policy'); - } + const decision = await this.getMessageBusDecision(abortSignal); + if (decision === 'ALLOW') { + return false; + } + if (decision === 'DENY') { + throw new Error('Tool execution denied by policy'); } return false; } } class TestTool extends BaseDeclarativeTool { - constructor(messageBus?: MessageBus) { + constructor(messageBus: MessageBus) { super( 'test-tool', 'Test Tool', @@ -84,14 +81,24 @@ class TestTool extends BaseDeclarativeTool { }, required: ['testParam'], }, + messageBus, true, false, - messageBus, ); } - protected createInvocation(params: TestParams, messageBus?: MessageBus) { - return new TestToolInvocation(params, messageBus); + protected createInvocation( + params: TestParams, + messageBus: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ) { + return new TestToolInvocation( + params, + messageBus, + _toolName, + _toolDisplayName, + ); } } @@ -131,7 +138,7 @@ describe('Message Bus Integration', () => { expect(publishSpy).toHaveBeenCalledWith({ type: MessageBusType.TOOL_CONFIRMATION_REQUEST, toolCall: { - name: 'TestToolInvocation', + name: 'test-tool', args: { testParam: 'test-value' }, }, correlationId: 'test-correlation-id', @@ -220,16 +227,6 @@ describe('Message Bus Integration', () => { ); }); - it('should fall back to default behavior when no message bus', async () => { - const tool = new TestTool(); // No message bus - const invocation = tool.build({ testParam: 'test-value' }); - - const result = await invocation.shouldConfirmExecute( - new AbortController().signal, - ); - expect(result).toBe(false); - }); - it('should ignore responses with wrong correlation ID', async () => { vi.useFakeTimers(); @@ -260,28 +257,6 @@ describe('Message Bus Integration', () => { }); }); - describe('Backward Compatibility', () => { - it('should work with existing tools that do not use message bus', async () => { - const tool = new TestTool(); // No message bus - const invocation = tool.build({ testParam: 'test-value' }); - - // Should execute normally - const result = await invocation.execute(new AbortController().signal); - expect(result.testValue).toBe('test-value'); - expect(result.llmContent).toBe('Executed with test-value'); - }); - - it('should work with tools that have message bus but use default confirmation', async () => { - const tool = new TestTool(messageBus); - const invocation = tool.build({ testParam: 'test-value' }); - - // Should execute normally even with message bus available - const result = await invocation.execute(new AbortController().signal); - expect(result.testValue).toBe('test-value'); - expect(result.llmContent).toBe('Executed with test-value'); - }); - }); - describe('Error Handling', () => { it('should handle message bus publish errors gracefully', async () => { const tool = new TestTool(messageBus); diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index a01c4a5261..0194e18288 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -17,6 +17,7 @@ import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; vi.mock('../telemetry/loggers.js', () => ({ logFileOperation: vi.fn(), @@ -46,7 +47,7 @@ describe('ReadFileTool', () => { }, isInteractive: () => false, } as unknown as Config; - tool = new ReadFileTool(mockConfigInstance); + tool = new ReadFileTool(mockConfigInstance, createMockMessageBus()); }); afterEach(async () => { @@ -438,7 +439,7 @@ describe('ReadFileTool', () => { getProjectTempDir: () => path.join(tempRootDir, '.temp'), }, } as unknown as Config; - tool = new ReadFileTool(mockConfigInstance); + tool = new ReadFileTool(mockConfigInstance, createMockMessageBus()); }); it('should throw error if path is ignored by a .geminiignore pattern', async () => { diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 4c0aed9565..f748bf8b45 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -50,7 +50,7 @@ class ReadFileToolInvocation extends BaseToolInvocation< constructor( private config: Config, params: ReadFileToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -149,7 +149,7 @@ export class ReadFileTool extends BaseDeclarativeTool< constructor( private config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( ReadFileTool.Name, @@ -176,9 +176,9 @@ export class ReadFileTool extends BaseDeclarativeTool< required: ['file_path'], type: 'object', }, + messageBus, true, false, - messageBus, ); } @@ -225,7 +225,7 @@ export class ReadFileTool extends BaseDeclarativeTool< protected createInvocation( params: ReadFileToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 20a06763c2..e092b6d6a5 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -21,6 +21,7 @@ import { DEFAULT_FILE_EXCLUDES, } from '../utils/ignorePatterns.js'; import * as glob from 'glob'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; vi.mock('glob', { spy: true }); @@ -90,7 +91,7 @@ describe('ReadManyFilesTool', () => { }), isInteractive: () => false, } as Partial as Config; - tool = new ReadManyFilesTool(mockConfig); + tool = new ReadManyFilesTool(mockConfig, createMockMessageBus()); mockReadFileFn = mockControl.mockReadFile; mockReadFileFn.mockReset(); @@ -505,7 +506,7 @@ describe('ReadManyFilesTool', () => { }), isInteractive: () => false, } as Partial as Config; - tool = new ReadManyFilesTool(mockConfig); + tool = new ReadManyFilesTool(mockConfig, createMockMessageBus()); fs.writeFileSync(path.join(tempDir1, 'file1.txt'), 'Content1'); fs.writeFileSync(path.join(tempDir2, 'file2.txt'), 'Content2'); diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 85c6c4b4aa..c1d8c18cd7 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -107,7 +107,7 @@ class ReadManyFilesToolInvocation extends BaseToolInvocation< constructor( private readonly config: Config, params: ReadManyFilesParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -447,7 +447,7 @@ export class ReadManyFilesTool extends BaseDeclarativeTool< constructor( private config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { const parameterSchema = { type: 'object', @@ -520,15 +520,15 @@ This tool is useful when you need to understand or analyze a collection of files Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. The tool inserts a '--- End of content ---' after the last file. Ensure glob patterns are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/audio/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/audio/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`, Kind.Read, parameterSchema, + messageBus, true, // isOutputMarkdown false, // canUpdateOutput - messageBus, ); } protected createInvocation( params: ReadManyFilesParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 0f978313ed..e8eafc9b23 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -24,6 +24,7 @@ import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.j import type { ChildProcess } from 'node:child_process'; import { spawn } from 'node:child_process'; import { downloadRipGrep } from '@joshua.litt/get-ripgrep'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; // Mock dependencies for canUseRipgrep vi.mock('@joshua.litt/get-ripgrep', () => ({ downloadRipGrep: vi.fn(), @@ -267,7 +268,7 @@ describe('RipGrepTool', () => { await fs.writeFile(ripgrepBinaryPath, ''); storageSpy.mockImplementation(() => binDir); tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); - grepTool = new RipGrepTool(mockConfig); + grepTool = new RipGrepTool(mockConfig, createMockMessageBus()); // Create some test files and directories await fs.writeFile( @@ -833,7 +834,10 @@ describe('RipGrepTool', () => { return mockProcess as unknown as ChildProcess; }); - const multiDirGrepTool = new RipGrepTool(multiDirConfig); + const multiDirGrepTool = new RipGrepTool( + multiDirConfig, + createMockMessageBus(), + ); const params: RipGrepToolParams = { pattern: 'world' }; const invocation = multiDirGrepTool.build(params); const result = await invocation.execute(abortSignal); @@ -927,7 +931,10 @@ describe('RipGrepTool', () => { return mockProcess as unknown as ChildProcess; }); - const multiDirGrepTool = new RipGrepTool(multiDirConfig); + const multiDirGrepTool = new RipGrepTool( + multiDirConfig, + createMockMessageBus(), + ); // Search only in the 'sub' directory of the first workspace const params: RipGrepToolParams = { pattern: 'world', dir_path: 'sub' }; @@ -1656,7 +1663,10 @@ describe('RipGrepTool', () => { getDebugMode: () => false, getFileFilteringRespectGeminiIgnore: () => true, } as unknown as Config; - const geminiIgnoreTool = new RipGrepTool(configWithGeminiIgnore); + const geminiIgnoreTool = new RipGrepTool( + configWithGeminiIgnore, + createMockMessageBus(), + ); mockSpawn.mockImplementationOnce( createMockSpawn({ @@ -1693,7 +1703,10 @@ describe('RipGrepTool', () => { getDebugMode: () => false, getFileFilteringRespectGeminiIgnore: () => false, } as unknown as Config; - const geminiIgnoreTool = new RipGrepTool(configWithoutGeminiIgnore); + const geminiIgnoreTool = new RipGrepTool( + configWithoutGeminiIgnore, + createMockMessageBus(), + ); mockSpawn.mockImplementationOnce( createMockSpawn({ @@ -1816,7 +1829,10 @@ describe('RipGrepTool', () => { getDebugMode: () => false, } as unknown as Config; - const multiDirGrepTool = new RipGrepTool(multiDirConfig); + const multiDirGrepTool = new RipGrepTool( + multiDirConfig, + createMockMessageBus(), + ); const params: RipGrepToolParams = { pattern: 'testPattern' }; const invocation = multiDirGrepTool.build(params); expect(invocation.getDescription()).toBe("'testPattern' within ./"); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 49a9398c16..0e52884b14 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -192,7 +192,7 @@ class GrepToolInvocation extends BaseToolInvocation< private readonly config: Config, private readonly geminiIgnoreParser: GeminiIgnoreParser, params: RipGrepToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -493,7 +493,7 @@ export class RipGrepTool extends BaseDeclarativeTool< constructor( private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( RipGrepTool.Name, @@ -551,9 +551,9 @@ export class RipGrepTool extends BaseDeclarativeTool< required: ['pattern'], type: 'object', }, + messageBus, true, // isOutputMarkdown false, // canUpdateOutput - messageBus, ); this.geminiIgnoreParser = new GeminiIgnoreParser(config.getTargetDir()); } @@ -586,7 +586,7 @@ export class RipGrepTool extends BaseDeclarativeTool< protected createInvocation( params: RipGrepToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { @@ -594,7 +594,7 @@ export class RipGrepTool extends BaseDeclarativeTool< this.config, this.geminiIgnoreParser, params, - messageBus, + messageBus ?? this.messageBus, _toolName, _toolDisplayName, ); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index c5ec9ce289..ed2ff86c5f 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -37,7 +37,6 @@ vi.mock('crypto'); vi.mock('../utils/summarizer.js'); import { initializeShellParsers } from '../utils/shell-utils.js'; -import { isCommandAllowed } from '../utils/shell-permissions.js'; import { ShellTool } from './shell.js'; import { type Config } from '../config/config.js'; import { @@ -55,6 +54,19 @@ import { ToolConfirmationOutcome } from './tools.js'; import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { SHELL_TOOL_NAME } from './tool-names.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { + createMockMessageBus, + getMockMessageBusInstance, +} from '../test-utils/mock-message-bus.js'; +import { + MessageBusType, + type UpdatePolicy, +} from '../confirmation-bus/types.js'; +import { type MessageBus } from '../confirmation-bus/message-bus.js'; + +interface TestableMockMessageBus extends MessageBus { + defaultToolDecision: 'allow' | 'deny' | 'ask_user'; +} const originalComSpec = process.env['ComSpec']; const itWindowsOnly = process.platform === 'win32' ? it : it.skip; @@ -93,7 +105,29 @@ describe('ShellTool', () => { getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000), } as unknown as Config; - shellTool = new ShellTool(mockConfig); + const bus = createMockMessageBus(); + const mockBus = getMockMessageBusInstance( + bus, + ) as unknown as TestableMockMessageBus; + mockBus.defaultToolDecision = 'ask_user'; + + // Simulate policy update + bus.subscribe(MessageBusType.UPDATE_POLICY, (msg: UpdatePolicy) => { + if (msg.commandPrefix) { + const prefixes = Array.isArray(msg.commandPrefix) + ? msg.commandPrefix + : [msg.commandPrefix]; + const current = mockConfig.getAllowedTools() || []; + (mockConfig.getAllowedTools as Mock).mockReturnValue([ + ...current, + ...prefixes, + ]); + // Simulate Policy Engine allowing the tool after update + mockBus.defaultToolDecision = 'allow'; + } + }); + + shellTool = new ShellTool(mockConfig, bus); mockPlatform.mockReturnValue('linux'); (vi.mocked(crypto.randomBytes) as Mock).mockReturnValue( @@ -125,25 +159,6 @@ describe('ShellTool', () => { } }); - describe('isCommandAllowed', () => { - it('should allow a command if no restrictions are provided', () => { - (mockConfig.getCoreTools as Mock).mockReturnValue(undefined); - (mockConfig.getExcludeTools as Mock).mockReturnValue(undefined); - expect(isCommandAllowed('goodCommand --safe', mockConfig).allowed).toBe( - true, - ); - }); - - it('should allow a command with command substitution using $()', () => { - const evaluation = isCommandAllowed( - 'echo $(goodCommand --safe)', - mockConfig, - ); - expect(evaluation.allowed).toBe(true); - expect(evaluation.reason).toBeUndefined(); - }); - }); - describe('build', () => { it('should return an invocation for a valid command', () => { const invocation = shellTool.build({ command: 'goodCommand --safe' }); @@ -475,6 +490,16 @@ describe('ShellTool', () => { it('should request confirmation for a new command and allowlist it on "Always"', async () => { const params = { command: 'npm install' }; const invocation = shellTool.build(params); + + // Accessing protected messageBus for testing purposes + const bus = (shellTool as unknown as { messageBus: MessageBus }) + .messageBus; + const mockBus = getMockMessageBusInstance( + bus, + ) as unknown as TestableMockMessageBus; + + // Initially needs confirmation + mockBus.defaultToolDecision = 'ask_user'; const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); @@ -482,12 +507,12 @@ describe('ShellTool', () => { expect(confirmation).not.toBe(false); expect(confirmation && confirmation.type).toBe('exec'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (confirmation as any).onConfirm( - ToolConfirmationOutcome.ProceedAlways, - ); + if (confirmation && confirmation.type === 'exec') { + await confirmation.onConfirm(ToolConfirmationOutcome.ProceedAlways); + } - // Should now be allowlisted + // After "Always", it should be allowlisted in the mock engine + mockBus.defaultToolDecision = 'allow'; const secondInvocation = shellTool.build({ command: 'npm test' }); const secondConfirmation = await secondInvocation.shouldConfirmExecute( new AbortController().signal, @@ -498,76 +523,18 @@ describe('ShellTool', () => { it('should throw an error if validation fails', () => { expect(() => shellTool.build({ command: '' })).toThrow(); }); - - describe('in non-interactive mode', () => { - beforeEach(() => { - (mockConfig.isInteractive as Mock).mockReturnValue(false); - }); - - it('should not throw an error or block for an allowed command', async () => { - (mockConfig.getAllowedTools as Mock).mockReturnValue(['ShellTool(wc)']); - const invocation = shellTool.build({ command: 'wc -l foo.txt' }); - const confirmation = await invocation.shouldConfirmExecute( - new AbortController().signal, - ); - expect(confirmation).toBe(false); - }); - - it('should not throw an error or block for an allowed command with arguments', async () => { - (mockConfig.getAllowedTools as Mock).mockReturnValue([ - 'ShellTool(wc -l)', - ]); - const invocation = shellTool.build({ command: 'wc -l foo.txt' }); - const confirmation = await invocation.shouldConfirmExecute( - new AbortController().signal, - ); - expect(confirmation).toBe(false); - }); - - it('should throw an error for command that is not allowed', async () => { - (mockConfig.getAllowedTools as Mock).mockReturnValue([ - 'ShellTool(wc -l)', - ]); - const invocation = shellTool.build({ command: 'madeupcommand' }); - await expect( - invocation.shouldConfirmExecute(new AbortController().signal), - ).rejects.toThrow('madeupcommand'); - }); - - it('should throw an error for a command that is a prefix of an allowed command', async () => { - (mockConfig.getAllowedTools as Mock).mockReturnValue([ - 'ShellTool(wc -l)', - ]); - const invocation = shellTool.build({ command: 'wc' }); - await expect( - invocation.shouldConfirmExecute(new AbortController().signal), - ).rejects.toThrow('wc'); - }); - - it('should require all segments of a chained command to be allowlisted', async () => { - (mockConfig.getAllowedTools as Mock).mockReturnValue([ - 'ShellTool(echo)', - ]); - const invocation = shellTool.build({ command: 'echo "foo" && ls -l' }); - await expect( - invocation.shouldConfirmExecute(new AbortController().signal), - ).rejects.toThrow( - 'Command "echo "foo" && ls -l" is not in the list of allowed tools for non-interactive mode.', - ); - }); - }); }); describe('getDescription', () => { it('should return the windows description when on windows', () => { mockPlatform.mockReturnValue('win32'); - const shellTool = new ShellTool(mockConfig); + const shellTool = new ShellTool(mockConfig, createMockMessageBus()); expect(shellTool.description).toMatchSnapshot(); }); it('should return the non-windows description when not on windows', () => { mockPlatform.mockReturnValue('linux'); - const shellTool = new ShellTool(mockConfig); + const shellTool = new ShellTool(mockConfig, createMockMessageBus()); expect(shellTool.description).toMatchSnapshot(); }); }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 9103480c5d..a2d3b611c5 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import os, { EOL } from 'node:os'; import crypto from 'node:crypto'; import type { Config } from '../config/config.js'; -import { debugLogger, type AnyToolInvocation } from '../index.js'; +import { debugLogger } from '../index.js'; import { ToolErrorType } from './tool-error.js'; import type { ToolInvocation, @@ -24,7 +24,6 @@ import { Kind, type PolicyUpdateOptions, } from './tools.js'; -import { ApprovalMode } from '../policy/types.js'; import { getErrorMessage } from '../utils/errors.js'; import { summarizeToolOutput } from '../utils/summarizer.js'; @@ -40,10 +39,6 @@ import { initializeShellParsers, stripShellWrapper, } from '../utils/shell-utils.js'; -import { - isCommandAllowed, - isShellInvocationAllowlisted, -} from '../utils/shell-permissions.js'; import { SHELL_TOOL_NAME } from './tool-names.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -62,8 +57,7 @@ export class ShellToolInvocation extends BaseToolInvocation< constructor( private readonly config: Config, params: ShellToolParams, - private readonly allowlist: Set, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -107,43 +101,26 @@ export class ShellToolInvocation extends BaseToolInvocation< _abortSignal: AbortSignal, ): Promise { const command = stripShellWrapper(this.params.command); - const rootCommands = [...new Set(getCommandRoots(command))]; + let rootCommands = [...new Set(getCommandRoots(command))]; - // In non-interactive mode, we need to prevent the tool from hanging while - // waiting for user input. If a tool is not fully allowed (e.g. via - // --allowed-tools="ShellTool(wc)"), we should throw an error instead of - // prompting for confirmation. This check is skipped in YOLO mode. - if ( - !this.config.isInteractive() && - this.config.getApprovalMode() !== ApprovalMode.YOLO - ) { - if (this.isInvocationAllowlisted(command)) { - // If it's an allowed shell command, we don't need to confirm execution. - return false; + // Fallback for UI display if parser fails or returns no commands (e.g. + // variable assignments only) + if (rootCommands.length === 0 && command.trim()) { + const fallback = command.trim().split(/\s+/)[0]; + if (fallback) { + rootCommands = [fallback]; } - - throw new Error( - `Command "${command}" is not in the list of allowed tools for non-interactive mode.`, - ); - } - - const commandsToConfirm = rootCommands.filter( - (command) => !this.allowlist.has(command), - ); - - if (commandsToConfirm.length === 0) { - return false; // already approved and allowlisted } + // Rely entirely on PolicyEngine for interactive confirmation. + // If we are here, it means PolicyEngine returned ASK_USER (or no message bus), + // so we must provide confirmation details. const confirmationDetails: ToolExecuteConfirmationDetails = { type: 'exec', title: 'Confirm Shell Command', command: this.params.command, - rootCommand: commandsToConfirm.join(', '), + rootCommand: rootCommands.join(', '), onConfirm: async (outcome: ToolConfirmationOutcome) => { - if (outcome === ToolConfirmationOutcome.ProceedAlways) { - commandsToConfirm.forEach((command) => this.allowlist.add(command)); - } await this.publishPolicyUpdate(outcome); }, }; @@ -403,16 +380,6 @@ export class ShellToolInvocation extends BaseToolInvocation< } } } - - private isInvocationAllowlisted(command: string): boolean { - const allowedTools = this.config.getAllowedTools() || []; - if (allowedTools.length === 0) { - return false; - } - - const invocation = { params: { command } } as unknown as AnyToolInvocation; - return isShellInvocationAllowlisted(invocation, allowedTools); - } } function getShellToolDescription(): string { @@ -451,11 +418,9 @@ export class ShellTool extends BaseDeclarativeTool< > { static readonly Name = SHELL_TOOL_NAME; - private allowlist: Set = new Set(); - constructor( private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { void initializeShellParsers().catch(() => { // Errors are surfaced when parsing commands. @@ -485,9 +450,9 @@ export class ShellTool extends BaseDeclarativeTool< }, required: ['command'], }, + messageBus, false, // output is not markdown true, // output can be updated - messageBus, ); } @@ -498,19 +463,6 @@ export class ShellTool extends BaseDeclarativeTool< return 'Command cannot be empty.'; } - const commandCheck = isCommandAllowed(params.command, this.config); - if (!commandCheck.allowed) { - if (!commandCheck.reason) { - debugLogger.error( - 'Unexpected: isCommandAllowed returned false without a reason', - ); - return `Command is not allowed: ${params.command}`; - } - return commandCheck.reason; - } - if (getCommandRoots(params.command).length === 0) { - return 'Could not identify command root to obtain permission from user.'; - } if (params.dir_path) { const resolvedPath = path.resolve( this.config.getTargetDir(), @@ -526,14 +478,13 @@ export class ShellTool extends BaseDeclarativeTool< protected createInvocation( params: ShellToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { return new ShellToolInvocation( this.config, params, - this.allowlist, messageBus, _toolName, _toolDisplayName, diff --git a/packages/core/src/tools/smart-edit.test.ts b/packages/core/src/tools/smart-edit.test.ts index 7c32c829c1..9c76d77ee4 100644 --- a/packages/core/src/tools/smart-edit.test.ts +++ b/packages/core/src/tools/smart-edit.test.ts @@ -49,6 +49,10 @@ import { } from './smart-edit.js'; import { type FileDiff, ToolConfirmationOutcome } from './tools.js'; import { ToolErrorType } from './tool-error.js'; +import { + createMockMessageBus, + getMockMessageBusInstance, +} from '../test-utils/mock-message-bus.js'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; @@ -89,7 +93,6 @@ describe('SmartEditTool', () => { getUsageStatisticsEnabled: vi.fn(() => true), getSessionId: vi.fn(() => 'mock-session-id'), getContentGeneratorConfig: vi.fn(() => ({ authType: 'mock' })), - getUseSmartEdit: vi.fn(() => false), getProxy: vi.fn(() => undefined), getGeminiClient: vi.fn().mockReturnValue(geminiClient), getBaseLlmClient: vi.fn().mockReturnValue(baseLlmClient), @@ -166,7 +169,9 @@ describe('SmartEditTool', () => { }, ); - tool = new SmartEditTool(mockConfig); + const bus = createMockMessageBus(); + getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; + tool = new SmartEditTool(mockConfig, bus); }); afterEach(() => { diff --git a/packages/core/src/tools/smart-edit.ts b/packages/core/src/tools/smart-edit.ts index cd1a563863..aee3a115f8 100644 --- a/packages/core/src/tools/smart-edit.ts +++ b/packages/core/src/tools/smart-edit.ts @@ -386,7 +386,7 @@ class EditToolInvocation constructor( private readonly config: Config, params: EditToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, toolName?: string, displayName?: string, ) { @@ -853,7 +853,7 @@ export class SmartEditTool constructor( private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( SmartEditTool.Name, @@ -915,9 +915,9 @@ A good instruction should concisely answer: required: ['file_path', 'instruction', 'old_string', 'new_string'], type: 'object', }, + messageBus, true, // isOutputMarkdown false, // canUpdateOutput - messageBus, ); } @@ -955,11 +955,12 @@ A good instruction should concisely answer: protected createInvocation( params: EditToolParams, + messageBus: MessageBus, ): ToolInvocation { return new EditToolInvocation( this.config, params, - this.messageBus, + messageBus, this.name, this.displayName, ); diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index db2967405e..41e4be8dec 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -21,6 +21,7 @@ export const READ_FILE_TOOL_NAME = 'read_file'; export const LS_TOOL_NAME = 'list_directory'; export const MEMORY_TOOL_NAME = 'save_memory'; export const GET_INTERNAL_DOCS_TOOL_NAME = 'get_internal_docs'; +export const ACTIVATE_SKILL_TOOL_NAME = 'activate_skill'; export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]); export const DELEGATE_TO_AGENT_TOOL_NAME = 'delegate_to_agent'; @@ -43,6 +44,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [ READ_FILE_TOOL_NAME, LS_TOOL_NAME, MEMORY_TOOL_NAME, + ACTIVATE_SKILL_TOOL_NAME, DELEGATE_TO_AGENT_TOOL_NAME, ] as const; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 55eb89150a..f665827e35 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -90,12 +90,26 @@ const createMockCallableTool = ( }); // Helper to create a DiscoveredMCPTool +const mockMessageBusForHelper = { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), +} as unknown as MessageBus; + const createMCPTool = ( serverName: string, toolName: string, description: string, mockCallable: CallableTool = {} as CallableTool, -) => new DiscoveredMCPTool(mockCallable, serverName, toolName, description, {}); +) => + new DiscoveredMCPTool( + mockCallable, + serverName, + toolName, + description, + {}, + mockMessageBusForHelper, + ); // Helper to create a mock spawn process for tool discovery const createDiscoveryProcess = (toolDeclarations: FunctionDeclaration[]) => { @@ -171,6 +185,11 @@ const baseConfigParams: ConfigParameters = { describe('ToolRegistry', () => { let config: Config; let toolRegistry: ToolRegistry; + const mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + } as unknown as MessageBus; let mockConfigGetToolDiscoveryCommand: ReturnType; let mockConfigGetExcludedTools: MockInstance< typeof Config.prototype.getExcludeTools @@ -182,7 +201,7 @@ describe('ToolRegistry', () => { isDirectory: () => true, } as fs.Stats); config = new Config(baseConfigParams); - toolRegistry = new ToolRegistry(config); + toolRegistry = new ToolRegistry(config, mockMessageBus); vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); vi.spyOn(console, 'debug').mockImplementation(() => {}); @@ -372,6 +391,7 @@ describe('ToolRegistry', () => { DISCOVERED_TOOL_PREFIX + 'discovered-1', 'desc', {}, + mockMessageBus, ); const mcpZebra = createMCPTool('zebra-server', 'mcp-zebra', 'desc'); const mcpApple = createMCPTool('apple-server', 'mcp-apple', 'desc'); @@ -482,13 +502,6 @@ describe('ToolRegistry', () => { const discoveryCommand = 'my-discovery-command'; mockConfigGetToolDiscoveryCommand.mockReturnValue(discoveryCommand); - const mockMessageBus = { - publish: vi.fn(), - subscribe: vi.fn(), - unsubscribe: vi.fn(), - } as unknown as MessageBus; - toolRegistry.setMessageBus(mockMessageBus); - const toolDeclaration: FunctionDeclaration = { name: 'policy-test-tool', description: 'tests policy', @@ -520,6 +533,7 @@ describe('ToolRegistry', () => { DISCOVERED_TOOL_PREFIX + 'test-tool', 'A test tool', {}, + mockMessageBus, ); const params = { param: 'testValue' }; const invocation = tool.build(params); diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 40b9fc16dc..18c30c5f76 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -34,7 +34,7 @@ class DiscoveredToolInvocation extends BaseToolInvocation< private readonly originalToolName: string, prefixedToolName: string, params: ToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super(params, messageBus, prefixedToolName); } @@ -135,7 +135,7 @@ export class DiscoveredTool extends BaseDeclarativeTool< prefixedName: string, description: string, override readonly parameterSchema: Record, - messageBus?: MessageBus, + messageBus: MessageBus, ) { const discoveryCmd = config.getToolDiscoveryCommand()!; const callCommand = config.getToolCallCommand()!; @@ -163,25 +163,25 @@ Signal: Signal number or \`(none)\` if no signal was received. fullDescription, Kind.Other, parameterSchema, + messageBus, false, // isOutputMarkdown false, // canUpdateOutput - messageBus, ); this.originalName = originalName; } protected createInvocation( params: ToolParams, - _messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _displayName?: string, ): ToolInvocation { return new DiscoveredToolInvocation( this.config, this.originalName, - this.name, + _toolName ?? this.name, params, - _messageBus, + messageBus, ); } } @@ -192,17 +192,14 @@ export class ToolRegistry { // and `isActive` to get only the active tools. private allKnownTools: Map = new Map(); private config: Config; - private messageBus?: MessageBus; + private messageBus: MessageBus; - constructor(config: Config) { + constructor(config: Config, messageBus: MessageBus) { this.config = config; - } - - setMessageBus(messageBus: MessageBus): void { this.messageBus = messageBus; } - getMessageBus(): MessageBus | undefined { + getMessageBus(): MessageBus { return this.messageBus; } diff --git a/packages/core/src/tools/tools.test.ts b/packages/core/src/tools/tools.test.ts index 38827268c1..514f4f3455 100644 --- a/packages/core/src/tools/tools.test.ts +++ b/packages/core/src/tools/tools.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import type { ToolInvocation, ToolResult } from './tools.js'; import { DeclarativeTool, hasCycleInSchema, Kind } from './tools.js'; import { ToolErrorType } from './tool-error.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; class TestToolInvocation implements ToolInvocation { constructor( @@ -36,7 +37,16 @@ class TestTool extends DeclarativeTool { private readonly buildFn: (params: object) => TestToolInvocation; constructor(buildFn: (params: object) => TestToolInvocation) { - super('test-tool', 'Test Tool', 'A tool for testing', Kind.Other, {}); + super( + 'test-tool', + 'Test Tool', + 'A tool for testing', + Kind.Other, + {}, + createMockMessageBus(), + true, // isOutputMarkdown + false, // canUpdateOutput + ); this.buildFn = buildFn; } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index d4b7fc3094..1b6f6f92ee 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -83,7 +83,7 @@ export abstract class BaseToolInvocation< { constructor( readonly params: TParams, - protected readonly messageBus?: MessageBus, + protected readonly messageBus: MessageBus, readonly _toolName?: string, readonly _toolDisplayName?: string, readonly _serverName?: string, @@ -98,25 +98,24 @@ export abstract class BaseToolInvocation< async shouldConfirmExecute( abortSignal: AbortSignal, ): Promise { - if (this.messageBus) { - const decision = await this.getMessageBusDecision(abortSignal); - if (decision === 'ALLOW') { - return false; - } - - if (decision === 'DENY') { - throw new Error( - `Tool execution for "${ - this._toolDisplayName || this._toolName - }" denied by policy.`, - ); - } - - if (decision === 'ASK_USER') { - return this.getConfirmationDetails(abortSignal); - } + const decision = await this.getMessageBusDecision(abortSignal); + if (decision === 'ALLOW') { + return false; } - // When no message bus, use default confirmation flow + + if (decision === 'DENY') { + throw new Error( + `Tool execution for "${ + this._toolDisplayName || this._toolName + }" denied by policy.`, + ); + } + + if (decision === 'ASK_USER') { + return this.getConfirmationDetails(abortSignal); + } + + // Default to confirmation details if decision is unknown (should not happen with exhaustive policy) return this.getConfirmationDetails(abortSignal); } @@ -142,7 +141,7 @@ export abstract class BaseToolInvocation< outcome === ToolConfirmationOutcome.ProceedAlways || outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ) { - if (this.messageBus && this._toolName) { + if (this._toolName) { const options = this.getPolicyUpdateOptions(outcome); await this.messageBus.publish({ type: MessageBusType.UPDATE_POLICY, @@ -206,7 +205,7 @@ export abstract class BaseToolInvocation< timeoutId = undefined; } abortSignal.removeEventListener('abort', abortHandler); - this.messageBus?.unsubscribe( + this.messageBus.unsubscribe( MessageBusType.TOOL_CONFIRMATION_RESPONSE, responseHandler, ); @@ -341,9 +340,9 @@ export abstract class DeclarativeTool< readonly description: string, readonly kind: Kind, readonly parameterSchema: unknown, + readonly messageBus: MessageBus, readonly isOutputMarkdown: boolean = true, readonly canUpdateOutput: boolean = false, - readonly messageBus?: MessageBus, readonly extensionName?: string, readonly extensionId?: string, ) {} @@ -496,7 +495,7 @@ export abstract class BaseDeclarativeTool< protected abstract createInvocation( params: TParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation; diff --git a/packages/core/src/tools/web-fetch.test.ts b/packages/core/src/tools/web-fetch.test.ts index f37db3d558..ac483fccd9 100644 --- a/packages/core/src/tools/web-fetch.test.ts +++ b/packages/core/src/tools/web-fetch.test.ts @@ -10,6 +10,10 @@ import type { Config } from '../config/config.js'; import { ApprovalMode } from '../policy/types.js'; import { ToolConfirmationOutcome } from './tools.js'; import { ToolErrorType } from './tool-error.js'; +import { + createMockMessageBus, + getMockMessageBusInstance, +} from '../test-utils/mock-message-bus.js'; import * as fetchUtils from '../utils/fetch.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import { PolicyEngine } from '../policy/policy-engine.js'; @@ -126,9 +130,12 @@ describe('parsePrompt', () => { describe('WebFetchTool', () => { let mockConfig: Config; + let bus: MessageBus; beforeEach(() => { vi.resetAllMocks(); + bus = createMockMessageBus(); + getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; mockConfig = { getApprovalMode: vi.fn(), setApprovalMode: vi.fn(), @@ -163,12 +170,12 @@ describe('WebFetchTool', () => { expectedError: 'Error(s) in prompt URLs:', }, ])('should throw if $name', ({ prompt, expectedError }) => { - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); expect(() => tool.build({ prompt })).toThrow(expectedError); }); it('should pass if prompt contains at least one valid URL', () => { - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); expect(() => tool.build({ prompt: 'fetch https://example.com' }), ).not.toThrow(); @@ -181,7 +188,7 @@ describe('WebFetchTool', () => { vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue( new Error('fetch failed'), ); - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://private.ip' }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); @@ -191,7 +198,7 @@ describe('WebFetchTool', () => { it('should return WEB_FETCH_PROCESSING_ERROR on general processing failure', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); mockGenerateContent.mockRejectedValue(new Error('API error')); - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://public.ip' }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); @@ -209,7 +216,7 @@ describe('WebFetchTool', () => { candidates: [{ content: { parts: [{ text: 'fallback response' }] } }], }); - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://private.ip' }; const invocation = tool.build(params); await invocation.execute(new AbortController().signal); @@ -237,7 +244,7 @@ describe('WebFetchTool', () => { candidates: [{ content: { parts: [{ text: 'fallback response' }] } }], }); - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://public.ip' }; const invocation = tool.build(params); await invocation.execute(new AbortController().signal); @@ -306,7 +313,7 @@ describe('WebFetchTool', () => { ], })); - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://example.com' }; const invocation = tool.build(params); const result = await invocation.execute(new AbortController().signal); @@ -330,7 +337,7 @@ describe('WebFetchTool', () => { describe('shouldConfirmExecute', () => { it('should return confirmation details with the correct prompt and parsed urls', async () => { - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://example.com' }; const invocation = tool.build(params); const confirmationDetails = await invocation.shouldConfirmExecute( @@ -347,7 +354,7 @@ describe('WebFetchTool', () => { }); it('should convert github urls to raw format', async () => { - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://github.com/google/gemini-react/blob/main/README.md', @@ -373,7 +380,7 @@ describe('WebFetchTool', () => { vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue( ApprovalMode.AUTO_EDIT, ); - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://example.com' }; const invocation = tool.build(params); const confirmationDetails = await invocation.shouldConfirmExecute( @@ -384,7 +391,7 @@ describe('WebFetchTool', () => { }); it('should call setApprovalMode when onConfirm is called with ProceedAlways', async () => { - const tool = new WebFetchTool(mockConfig); + const tool = new WebFetchTool(mockConfig, bus); const params = { prompt: 'fetch https://example.com' }; const invocation = tool.build(params); const confirmationDetails = await invocation.shouldConfirmExecute( @@ -412,8 +419,8 @@ describe('WebFetchTool', () => { let messageBus: MessageBus; let mockUUID: Mock; - const createToolWithMessageBus = (bus?: MessageBus) => { - const tool = new WebFetchTool(mockConfig, bus); + const createToolWithMessageBus = (customBus?: MessageBus) => { + const tool = new WebFetchTool(mockConfig, customBus ?? bus); const params = { prompt: 'fetch https://example.com' }; return { tool, invocation: tool.build(params) }; }; @@ -516,16 +523,6 @@ describe('WebFetchTool', () => { ); }); - it('should fall back to legacy confirmation when no message bus', async () => { - const { invocation } = createToolWithMessageBus(); // No message bus - const result = await invocation.shouldConfirmExecute( - new AbortController().signal, - ); - - expect(result).not.toBe(false); - expect(result).toHaveProperty('type', 'info'); - }); - it('should ignore responses with wrong correlation ID', async () => { vi.useFakeTimers(); const { invocation } = createToolWithMessageBus(messageBus); diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 92c0ae9fea..3f8df7fa14 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -114,7 +114,7 @@ class WebFetchToolInvocation extends BaseToolInvocation< constructor( private readonly config: Config, params: WebFetchToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -218,7 +218,8 @@ ${textContent} protected override async getConfirmationDetails( _abortSignal: AbortSignal, ): Promise { - // Legacy confirmation flow (no message bus OR policy decision was ASK_USER) + // Check for AUTO_EDIT approval mode. This tool has a specific behavior + // where ProceedAlways switches the entire session to AUTO_EDIT. if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { return false; } @@ -406,7 +407,7 @@ export class WebFetchTool extends BaseDeclarativeTool< constructor( private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( WebFetchTool.Name, @@ -424,9 +425,9 @@ export class WebFetchTool extends BaseDeclarativeTool< required: ['prompt'], type: 'object', }, + messageBus, true, // isOutputMarkdown false, // canUpdateOutput - messageBus, ); } @@ -452,7 +453,7 @@ export class WebFetchTool extends BaseDeclarativeTool< protected createInvocation( params: WebFetchToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { diff --git a/packages/core/src/tools/web-search.test.ts b/packages/core/src/tools/web-search.test.ts index 560e17e4ce..3812a54879 100644 --- a/packages/core/src/tools/web-search.test.ts +++ b/packages/core/src/tools/web-search.test.ts @@ -11,6 +11,7 @@ import { WebSearchTool } from './web-search.js'; import type { Config } from '../config/config.js'; import { GeminiClient } from '../core/client.js'; import { ToolErrorType } from './tool-error.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; // Mock GeminiClient and Config constructor vi.mock('../core/client.js'); @@ -33,7 +34,7 @@ describe('WebSearchTool', () => { }, } as unknown as Config; mockGeminiClient = new GeminiClient(mockConfigInstance); - tool = new WebSearchTool(mockConfigInstance); + tool = new WebSearchTool(mockConfigInstance, createMockMessageBus()); }); afterEach(() => { diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts index 1d81bf96d6..5a1eeffb6d 100644 --- a/packages/core/src/tools/web-search.ts +++ b/packages/core/src/tools/web-search.ts @@ -65,7 +65,7 @@ class WebSearchToolInvocation extends BaseToolInvocation< constructor( private readonly config: Config, params: WebSearchToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -192,7 +192,7 @@ export class WebSearchTool extends BaseDeclarativeTool< constructor( private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( WebSearchTool.Name, @@ -209,9 +209,9 @@ export class WebSearchTool extends BaseDeclarativeTool< }, required: ['query'], }, + messageBus, true, // isOutputMarkdown false, // canUpdateOutput - messageBus, ); } @@ -231,14 +231,14 @@ export class WebSearchTool extends BaseDeclarativeTool< protected createInvocation( params: WebSearchToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ): ToolInvocation { return new WebSearchToolInvocation( this.config, params, - messageBus, + messageBus ?? this.messageBus, _toolName, _toolDisplayName, ); diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 9d7e32a0ac..cd5436e7be 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -41,6 +41,10 @@ import { StandardFileSystemService } from '../services/fileSystemService.js'; import type { DiffUpdateResult } from '../ide/ide-client.js'; import { IdeClient } from '../ide/ide-client.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; +import { + createMockMessageBus, + getMockMessageBusInstance, +} from '../test-utils/mock-message-bus.js'; const rootDir = path.resolve(os.tmpdir(), 'gemini-cli-test-root'); @@ -150,7 +154,9 @@ describe('WriteFileTool', () => { mockBaseLlmClientInstance, ); - tool = new WriteFileTool(mockConfig); + const bus = createMockMessageBus(); + getMockMessageBusInstance(bus).defaultToolDecision = 'ask_user'; + tool = new WriteFileTool(mockConfig, bus); // Reset mocks before each test mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index bd832ac537..339a60b4b6 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -149,7 +149,7 @@ class WriteFileToolInvocation extends BaseToolInvocation< constructor( private readonly config: Config, params: WriteFileToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, toolName?: string, displayName?: string, ) { @@ -409,7 +409,7 @@ export class WriteFileTool constructor( private readonly config: Config, - messageBus?: MessageBus, + messageBus: MessageBus, ) { super( WriteFileTool.Name, @@ -432,9 +432,9 @@ export class WriteFileTool required: ['file_path', 'content'], type: 'object', }, + messageBus, true, false, - messageBus, ); } @@ -475,11 +475,12 @@ export class WriteFileTool protected createInvocation( params: WriteFileToolParams, + messageBus: MessageBus, ): ToolInvocation { return new WriteFileToolInvocation( this.config, params, - this.messageBus, + messageBus ?? this.messageBus, this.name, this.displayName, ); diff --git a/packages/core/src/tools/write-todos.test.ts b/packages/core/src/tools/write-todos.test.ts index 9c2bc36fa5..117a3d2681 100644 --- a/packages/core/src/tools/write-todos.test.ts +++ b/packages/core/src/tools/write-todos.test.ts @@ -6,9 +6,10 @@ import { describe, expect, it } from 'vitest'; import { WriteTodosTool, type WriteTodosToolParams } from './write-todos.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; describe('WriteTodosTool', () => { - const tool = new WriteTodosTool(); + const tool = new WriteTodosTool(createMockMessageBus()); const signal = new AbortController().signal; describe('validation', () => { diff --git a/packages/core/src/tools/write-todos.ts b/packages/core/src/tools/write-todos.ts index a418fac23d..6f12574107 100644 --- a/packages/core/src/tools/write-todos.ts +++ b/packages/core/src/tools/write-todos.ts @@ -101,7 +101,7 @@ class WriteTodosToolInvocation extends BaseToolInvocation< > { constructor( params: WriteTodosToolParams, - messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _toolDisplayName?: string, ) { @@ -145,7 +145,7 @@ export class WriteTodosTool extends BaseDeclarativeTool< > { static readonly Name = WRITE_TODOS_TOOL_NAME; - constructor() { + constructor(messageBus: MessageBus) { super( WriteTodosTool.Name, 'WriteTodos', @@ -180,6 +180,9 @@ export class WriteTodosTool extends BaseDeclarativeTool< required: ['todos'], additionalProperties: false, }, + messageBus, + true, // isOutputMarkdown + false, // canUpdateOutput ); } @@ -248,13 +251,13 @@ export class WriteTodosTool extends BaseDeclarativeTool< protected createInvocation( params: WriteTodosToolParams, - _messageBus?: MessageBus, + messageBus: MessageBus, _toolName?: string, _displayName?: string, ): ToolInvocation { return new WriteTodosToolInvocation( params, - _messageBus, + messageBus, _toolName, _displayName, ); diff --git a/packages/core/src/utils/editCorrector.test.ts b/packages/core/src/utils/editCorrector.test.ts index 913c2d9a63..02b8c1a3fb 100644 --- a/packages/core/src/utils/editCorrector.test.ts +++ b/packages/core/src/utils/editCorrector.test.ts @@ -164,7 +164,10 @@ describe('editCorrector', () => { const abortSignal = new AbortController().signal; beforeEach(() => { - mockToolRegistry = new ToolRegistry({} as Config) as Mocked; + mockToolRegistry = new ToolRegistry( + {} as Config, + {} as any, + ) as Mocked; const configParams = { apiKey: 'test-api-key', model: 'test-model', diff --git a/packages/core/src/utils/getFolderStructure.ts b/packages/core/src/utils/getFolderStructure.ts index 0544f4655d..8f871e1283 100644 --- a/packages/core/src/utils/getFolderStructure.ts +++ b/packages/core/src/utils/getFolderStructure.ts @@ -18,7 +18,12 @@ import { debugLogger } from './debugLogger.js'; const MAX_ITEMS = 200; const TRUNCATION_INDICATOR = '...'; -const DEFAULT_IGNORED_FOLDERS = new Set(['node_modules', '.git', 'dist']); +const DEFAULT_IGNORED_FOLDERS = new Set([ + 'node_modules', + '.git', + 'dist', + '__pycache__', +]); // --- Interfaces --- diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index a7abbfb43d..443e7d9182 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -19,6 +19,7 @@ import { getShellConfiguration, initializeShellParsers, stripShellWrapper, + hasRedirection, } from './shell-utils.js'; const mockPlatform = vi.hoisted(() => vi.fn()); @@ -32,6 +33,12 @@ vi.mock('os', () => ({ homedir: mockHomedir, })); +const mockSpawnSync = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', () => ({ + spawnSync: mockSpawnSync, + spawn: vi.fn(), +})); + const mockQuote = vi.hoisted(() => vi.fn()); vi.mock('shell-quote', () => ({ quote: mockQuote, @@ -50,12 +57,36 @@ beforeEach(() => { mockQuote.mockImplementation((args: string[]) => args.map((arg) => `'${arg}'`).join(' '), ); + mockSpawnSync.mockReturnValue({ + stdout: Buffer.from(''), + stderr: Buffer.from(''), + status: 0, + error: undefined, + }); }); afterEach(() => { vi.clearAllMocks(); }); +const mockPowerShellResult = ( + commands: Array<{ name: string; text: string }>, + hasRedirection: boolean, +) => { + mockSpawnSync.mockReturnValue({ + stdout: Buffer.from( + JSON.stringify({ + success: true, + commands, + hasRedirection, + }), + ), + stderr: Buffer.from(''), + status: 0, + error: undefined, + }); +}; + describe('getCommandRoots', () => { it('should return a single command', () => { expect(getCommandRoots('ls -l')).toEqual(['ls']); @@ -105,6 +136,64 @@ describe('getCommandRoots', () => { const roots = getCommandRoots('echo ${foo@P}'); expect(roots).toEqual([]); }); + + it('should include nested command substitutions in redirected statements', () => { + const result = getCommandRoots('echo $(cat secret) > output.txt'); + expect(result).toEqual(['echo', 'cat']); + }); + + it('should handle parser initialization failures gracefully', async () => { + // Reset modules to clear singleton state + vi.resetModules(); + + // Mock fileUtils to fail Wasm loading + vi.doMock('./fileUtils.js', () => ({ + loadWasmBinary: vi.fn().mockRejectedValue(new Error('Wasm load failed')), + })); + + // Re-import shell-utils with mocked dependencies + const shellUtils = await import('./shell-utils.js'); + + // Should catch the error and not throw + await expect(shellUtils.initializeShellParsers()).resolves.not.toThrow(); + + // Fallback: splitting commands depends on parser, so if parser fails, it returns empty + const roots = shellUtils.getCommandRoots('ls -la'); + expect(roots).toEqual([]); + }); +}); + +describe('hasRedirection', () => { + it('should detect output redirection', () => { + expect(hasRedirection('echo hello > world')).toBe(true); + }); + + it('should detect input redirection', () => { + expect(hasRedirection('cat < input')).toBe(true); + }); + + it('should detect append redirection', () => { + expect(hasRedirection('echo hello >> world')).toBe(true); + }); + + it('should detect heredoc', () => { + expect(hasRedirection('cat < { + expect(hasRedirection('cat <<< "hello"')).toBe(true); + }); + + it('should return false for simple commands', () => { + expect(hasRedirection('ls -la')).toBe(false); + }); + + it('should return false for pipes (pipes are not redirections in this context)', () => { + // Note: pipes are often handled separately by splitCommands, but checking here confirms they don't trigger "redirection" flag if we don't want them to. + // However, the current implementation checks for 'redirected_statement' nodes. + // A pipe is a 'pipeline' node. + expect(hasRedirection('echo hello | cat')).toBe(false); + }); }); describeWindowsOnly('PowerShell integration', () => { @@ -126,6 +215,14 @@ describeWindowsOnly('PowerShell integration', () => { }); it('should return command roots using PowerShell AST output', () => { + mockPowerShellResult( + [ + { name: 'Get-ChildItem', text: 'Get-ChildItem' }, + { name: 'Select-Object', text: 'Select-Object Name' }, + ], + false, + ); + const roots = getCommandRoots('Get-ChildItem | Select-Object Name'); expect(roots.length).toBeGreaterThan(0); expect(roots).toContain('Get-ChildItem'); @@ -300,3 +397,37 @@ describe('getShellConfiguration', () => { }); }); }); + +describe('hasRedirection (PowerShell via mock)', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('win32'); + process.env['ComSpec'] = 'powershell.exe'; + }); + + it('should return true when PowerShell parser detects redirection', () => { + mockPowerShellResult([{ name: 'echo', text: 'echo hello' }], true); + expect(hasRedirection('echo hello > file.txt')).toBe(true); + }); + + it('should return false when PowerShell parser does not detect redirection', () => { + mockPowerShellResult([{ name: 'echo', text: 'echo hello' }], false); + expect(hasRedirection('echo hello')).toBe(false); + }); + + it('should return false when quoted redirection chars are used but not actual redirection', () => { + mockPowerShellResult( + [{ name: 'echo', text: 'echo "-> arrow"' }], + false, // Parser says NO redirection + ); + expect(hasRedirection('echo "-> arrow"')).toBe(false); + }); + + it('should fallback to regex if parsing fails (simulating safety)', () => { + mockSpawnSync.mockReturnValue({ + stdout: Buffer.from('invalid json'), + status: 0, + }); + // Fallback regex sees '>' in arrow + expect(hasRedirection('echo "-> arrow"')).toBe(true); + }); +}); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 20e6a9c18f..609c0e28d5 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -98,7 +98,9 @@ export async function initializeShellParsers(): Promise { if (!treeSitterInitialization) { treeSitterInitialization = loadBashLanguage().catch((error) => { treeSitterInitialization = null; - throw error; + // Log the error but don't throw, allowing the application to fall back to safe defaults (ASK_USER) + // or regex checks where appropriate. + debugLogger.debug('Failed to initialize shell parsers:', error); }); } @@ -113,6 +115,7 @@ export interface ParsedCommandDetail { interface CommandParseResult { details: ParsedCommandDetail[]; hasError: boolean; + hasRedirection?: boolean; } const POWERSHELL_COMMAND_ENV = '__GCLI_POWERSHELL_COMMAND__'; @@ -136,7 +139,11 @@ if ($errors -and $errors.Count -gt 0) { } $commandAsts = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) $commandObjects = @() +$hasRedirection = $false foreach ($commandAst in $commandAsts) { + if ($commandAst.Redirections.Count -gt 0) { + $hasRedirection = $true + } $name = $commandAst.GetCommandName() if ([string]::IsNullOrWhiteSpace($name)) { continue @@ -149,6 +156,7 @@ foreach ($commandAst in $commandAsts) { [PSCustomObject]@{ success = $true commands = $commandObjects + hasRedirection = $hasRedirection } | ConvertTo-Json -Compress `, 'utf16le', @@ -230,22 +238,45 @@ function collectCommandDetails( const details: ParsedCommandDetail[] = []; while (stack.length > 0) { - const current = stack.pop(); - if (!current) { - continue; + const current = stack.pop()!; + + let name: string | null = null; + let ignoreChildId: number | undefined; + + if (current.type === 'redirected_statement') { + const body = current.childForFieldName('body'); + if (body) { + const bodyName = extractNameFromNode(body); + if (bodyName) { + name = bodyName; + ignoreChildId = body.id; + + // If we ignore the body node (because we used it to name the redirected_statement), + // we must still traverse its children to find nested commands (e.g. command substitution). + for (let i = body.namedChildCount - 1; i >= 0; i -= 1) { + const grandChild = body.namedChild(i); + if (grandChild) { + stack.push(grandChild); + } + } + } + } } - const commandName = extractNameFromNode(current); - if (commandName) { + if (!name) { + name = extractNameFromNode(current); + } + + if (name) { details.push({ - name: commandName, + name, text: source.slice(current.startIndex, current.endIndex).trim(), }); } for (let i = current.namedChildCount - 1; i >= 0; i -= 1) { const child = current.namedChild(i); - if (child) { + if (child && child.id !== ignoreChildId) { stack.push(child); } } @@ -290,7 +321,11 @@ function hasPromptCommandTransform(root: Node): boolean { function parseBashCommandDetails(command: string): CommandParseResult | null { if (treeSitterInitializationError) { - throw treeSitterInitializationError; + debugLogger.debug( + 'Bash parser not initialized:', + treeSitterInitializationError, + ); + return null; } if (!bashLanguage) { @@ -384,6 +419,7 @@ function parsePowerShellCommandDetails( let parsed: { success?: boolean; commands?: Array<{ name?: string; text?: string }>; + hasRedirection?: boolean; } | null = null; try { parsed = JSON.parse(output); @@ -417,6 +453,7 @@ function parsePowerShellCommandDetails( return { details, hasError: details.length === 0, + hasRedirection: parsed.hasRedirection, }; } catch { return null; @@ -514,6 +551,50 @@ export function escapeShellArg(arg: string, shell: ShellType): string { * @param command The shell command string to parse * @returns An array of individual command strings */ +/** + * Checks if a command contains redirection operators. + * Uses shell-specific parsers where possible, falling back to a broad regex check. + */ +export function hasRedirection(command: string): boolean { + const fallbackCheck = () => /[><]/.test(command); + const configuration = getShellConfiguration(); + + if (configuration.shell === 'powershell') { + const parsed = parsePowerShellCommandDetails( + command, + configuration.executable, + ); + return parsed && !parsed.hasError + ? !!parsed.hasRedirection + : fallbackCheck(); + } + + if (configuration.shell === 'bash' && bashLanguage) { + const tree = parseCommandTree(command); + if (!tree) return fallbackCheck(); + + const stack: Node[] = [tree.rootNode]; + while (stack.length > 0) { + const current = stack.pop()!; + if ( + current.type === 'redirected_statement' || + current.type === 'file_redirect' || + current.type === 'heredoc_redirect' || + current.type === 'herestring_redirect' + ) { + return true; + } + for (let i = current.childCount - 1; i >= 0; i -= 1) { + const child = current.child(i); + if (child) stack.push(child); + } + } + return false; + } + + return fallbackCheck(); +} + export function splitCommands(command: string): string[] { const parsed = parseCommandDetails(command); if (!parsed || parsed.hasError) { diff --git a/packages/core/src/utils/tool-utils.test.ts b/packages/core/src/utils/tool-utils.test.ts index 861281b2da..822d0c2999 100644 --- a/packages/core/src/utils/tool-utils.test.ts +++ b/packages/core/src/utils/tool-utils.test.ts @@ -8,6 +8,7 @@ import { expect, describe, it } from 'vitest'; import { doesToolInvocationMatch, getToolSuggestion } from './tool-utils.js'; import type { AnyToolInvocation, Config } from '../index.js'; import { ReadFileTool } from '../tools/read-file.js'; +import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; describe('getToolSuggestion', () => { it('should suggest the top N closest tool names for a typo', () => { @@ -83,7 +84,7 @@ describe('doesToolInvocationMatch', () => { }); describe('for non-shell tools', () => { - const readFileTool = new ReadFileTool({} as Config); + const readFileTool = new ReadFileTool({} as Config, createMockMessageBus()); const invocation = { params: { file: 'test.txt' }, } as AnyToolInvocation; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index e9630b354e..cbf1667576 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1179,13 +1179,6 @@ }, "additionalProperties": false }, - "useSmartEdit": { - "title": "Use Smart Edit", - "description": "Enable the smart-edit tool instead of the replace tool.", - "markdownDescription": "Enable the smart-edit tool instead of the replace tool.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`", - "default": true, - "type": "boolean" - }, "useWriteTodos": { "title": "Use WriteTodos", "description": "Enable the write_todos tool.", diff --git a/scripts/generate-git-commit-info.js b/scripts/generate-git-commit-info.js index dda65bede5..049c39c249 100644 --- a/scripts/generate-git-commit-info.js +++ b/scripts/generate-git-commit-info.js @@ -57,7 +57,7 @@ try { const fileContent = `/** * @license - * Copyright 2025 Google LLC + * Copyright ${new Date().getUTCFullYear()} Google LLC * SPDX-License-Identifier: Apache-2.0 */