mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-15 21:39:13 +00:00
Compare commits
20 Commits
web-search
...
mingholy/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e09d2b5ea | ||
|
|
d17c37af7d | ||
|
|
decb04efc4 | ||
|
|
3bd0cb36c4 | ||
|
|
553a36302a | ||
|
|
498d7a083a | ||
|
|
3a69931791 | ||
|
|
d4ab328671 | ||
|
|
90500ea67b | ||
|
|
335e765df0 | ||
|
|
448e30bf88 | ||
|
|
26215b6d0a | ||
|
|
f6f76a17e6 | ||
|
|
55a3b69a8e | ||
|
|
22bd108775 | ||
|
|
7e827833bf | ||
|
|
45f1000dea | ||
|
|
04f0996327 | ||
|
|
d8cc0a1f04 | ||
|
|
50d5cc2f6a |
@@ -541,6 +541,9 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- Displays the version of the CLI.
|
||||
- **`--openai-logging`**:
|
||||
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
|
||||
- **`--openai-logging-dir <directory>`**:
|
||||
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
|
||||
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
|
||||
- **`--tavily-api-key <api_key>`**:
|
||||
- Sets the Tavily API key for web search functionality for this session.
|
||||
- Example: `qwen --tavily-api-key tvly-your-api-key-here`
|
||||
|
||||
@@ -171,6 +171,18 @@ Settings are organized into categories. All settings should be placed within the
|
||||
- **Description:** Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`model.enableOpenAILogging`** (boolean):
|
||||
- **Description:** Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`model.openAILoggingDir`** (string):
|
||||
- **Description:** Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory).
|
||||
- **Default:** `undefined`
|
||||
- **Examples:**
|
||||
- `"~/qwen-logs"` - Logs to `~/qwen-logs` directory
|
||||
- `"./custom-logs"` - Logs to `./custom-logs` relative to current directory
|
||||
- `"/tmp/openai-logs"` - Logs to absolute path `/tmp/openai-logs`
|
||||
|
||||
#### `context`
|
||||
|
||||
- **`context.fileName`** (string or array of strings):
|
||||
@@ -387,6 +399,8 @@ Here is an example of a `settings.json` file with the nested structure, new as o
|
||||
"model": {
|
||||
"name": "qwen3-coder-plus",
|
||||
"maxSessionTurns": 10,
|
||||
"enableOpenAILogging": false,
|
||||
"openAILoggingDir": "~/qwen-logs",
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 100
|
||||
@@ -557,6 +571,9 @@ Arguments passed directly when running the CLI can override other configurations
|
||||
- Displays the version of the CLI.
|
||||
- **`--openai-logging`**:
|
||||
- Enables logging of OpenAI API calls for debugging and analysis. This flag overrides the `enableOpenAILogging` setting in `settings.json`.
|
||||
- **`--openai-logging-dir <directory>`**:
|
||||
- Sets a custom directory path for OpenAI API logs. This flag overrides the `openAILoggingDir` setting in `settings.json`. Supports absolute paths, relative paths, and `~` expansion.
|
||||
- **Example:** `qwen --openai-logging-dir "~/qwen-logs" --openai-logging`
|
||||
- **`--tavily-api-key <api_key>`**:
|
||||
- Sets the Tavily API key for web search functionality for this session.
|
||||
- Example: `qwen --tavily-api-key tvly-your-api-key-here`
|
||||
|
||||
@@ -68,72 +68,66 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
|
||||
- **File:** `glob.ts`
|
||||
- **Parameters:**
|
||||
- `pattern` (string, required): The glob pattern to match against (e.g., `"*.py"`, `"src/**/*.js"`).
|
||||
- `path` (string, optional): The absolute path to the directory to search within. If omitted, searches the tool's root directory.
|
||||
- `case_sensitive` (boolean, optional): Whether the search should be case-sensitive. Defaults to `false`.
|
||||
- `respect_git_ignore` (boolean, optional): Whether to respect .gitignore patterns when finding files. Defaults to `true`.
|
||||
- `path` (string, optional): The directory to search in. If not specified, the current working directory will be used.
|
||||
- **Behavior:**
|
||||
- Searches for files matching the glob pattern within the specified directory.
|
||||
- Returns a list of absolute paths, sorted with the most recently modified files first.
|
||||
- Ignores common nuisance directories like `node_modules` and `.git` by default.
|
||||
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts...`
|
||||
- Respects .gitignore and .qwenignore patterns by default.
|
||||
- Limits results to 100 files to prevent context overflow.
|
||||
- **Output (`llmContent`):** A message like: `Found 5 file(s) matching "*.ts" within /path/to/search/dir, sorted by modification time (newest first):\n---\n/path/to/file1.ts\n/path/to/subdir/file2.ts\n---\n[95 files truncated] ...`
|
||||
- **Confirmation:** No.
|
||||
|
||||
## 5. `search_file_content` (SearchText)
|
||||
## 5. `grep_search` (Grep)
|
||||
|
||||
`search_file_content` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
|
||||
`grep_search` searches for a regular expression pattern within the content of files in a specified directory. Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.
|
||||
|
||||
- **Tool name:** `search_file_content`
|
||||
- **Display name:** SearchText
|
||||
- **File:** `grep.ts`
|
||||
- **Tool name:** `grep_search`
|
||||
- **Display name:** Grep
|
||||
- **File:** `ripGrep.ts` (with `grep.ts` as fallback)
|
||||
- **Parameters:**
|
||||
- `pattern` (string, required): The regular expression (regex) to search for (e.g., `"function\s+myFunction"`).
|
||||
- `path` (string, optional): The absolute path to the directory to search within. Defaults to the current working directory.
|
||||
- `include` (string, optional): A glob pattern to filter which files are searched (e.g., `"*.js"`, `"src/**/*.{ts,tsx}"`). If omitted, searches most files (respecting common ignores).
|
||||
- `maxResults` (number, optional): Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.
|
||||
- `pattern` (string, required): The regular expression pattern to search for in file contents (e.g., `"function\\s+myFunction"`, `"log.*Error"`).
|
||||
- `path` (string, optional): File or directory to search in. Defaults to current working directory.
|
||||
- `glob` (string, optional): Glob pattern to filter files (e.g. `"*.js"`, `"src/**/*.{ts,tsx}"`).
|
||||
- `limit` (number, optional): Limit output to first N matching lines. Optional - shows all matches if not specified.
|
||||
- **Behavior:**
|
||||
- Uses `git grep` if available in a Git repository for speed; otherwise, falls back to system `grep` or a JavaScript-based search.
|
||||
- Returns a list of matching lines, each prefixed with its file path (relative to the search directory) and line number.
|
||||
- Limits results to a maximum of 20 matches by default to prevent context overflow. When results are truncated, shows a clear warning with guidance on refining searches.
|
||||
- Uses ripgrep for fast search when available; otherwise falls back to a JavaScript-based search implementation.
|
||||
- Returns matching lines with file paths and line numbers.
|
||||
- Case-insensitive by default.
|
||||
- Respects .gitignore and .qwenignore patterns.
|
||||
- Limits output to prevent context overflow.
|
||||
- **Output (`llmContent`):** A formatted string of matches, e.g.:
|
||||
|
||||
```
|
||||
Found 3 matches for pattern "myFunction" in path "." (filter: "*.ts"):
|
||||
---
|
||||
File: src/utils.ts
|
||||
L15: export function myFunction() {
|
||||
L22: myFunction.call();
|
||||
---
|
||||
File: src/index.ts
|
||||
L5: import { myFunction } from './utils';
|
||||
src/utils.ts:15:export function myFunction() {
|
||||
src/utils.ts:22: myFunction.call();
|
||||
src/index.ts:5:import { myFunction } from './utils';
|
||||
---
|
||||
|
||||
WARNING: Results truncated to prevent context overflow. To see more results:
|
||||
- Use a more specific pattern to reduce matches
|
||||
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
|
||||
- Specify a narrower 'path' to search in a subdirectory
|
||||
- Increase 'maxResults' parameter if you need more matches (current: 20)
|
||||
[0 lines truncated] ...
|
||||
```
|
||||
|
||||
- **Confirmation:** No.
|
||||
|
||||
### `search_file_content` examples
|
||||
### `grep_search` examples
|
||||
|
||||
Search for a pattern with default result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function\s+myFunction", path="src")
|
||||
grep_search(pattern="function\\s+myFunction", path="src")
|
||||
```
|
||||
|
||||
Search for a pattern with custom result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function", path="src", maxResults=50)
|
||||
grep_search(pattern="function", path="src", limit=50)
|
||||
```
|
||||
|
||||
Search for a pattern with file filtering and custom result limiting:
|
||||
|
||||
```
|
||||
search_file_content(pattern="function", include="*.js", maxResults=10)
|
||||
grep_search(pattern="function", glob="*.js", limit=10)
|
||||
```
|
||||
|
||||
## 6. `edit` (Edit)
|
||||
|
||||
55
package-lock.json
generated
55
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -555,6 +555,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -578,6 +579,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2118,6 +2120,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -3279,6 +3282,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -3717,6 +3721,7 @@
|
||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -3727,6 +3732,7 @@
|
||||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
@@ -3932,6 +3938,7 @@
|
||||
"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",
|
||||
@@ -4700,6 +4707,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -5054,8 +5062,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
@@ -6220,7 +6227,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7254,6 +7260,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7723,7 +7730,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -7785,7 +7791,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -7795,7 +7800,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -7805,7 +7809,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -7972,7 +7975,6 @@
|
||||
"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",
|
||||
@@ -7991,7 +7993,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -8000,15 +8001,13 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
@@ -9047,6 +9046,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
|
||||
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.0",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -10950,7 +10950,6 @@
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -12158,8 +12157,7 @@
|
||||
"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",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "3.0.0",
|
||||
@@ -12663,6 +12661,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12673,6 +12672,7 @@
|
||||
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -12706,6 +12706,7 @@
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -14515,6 +14516,7 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14688,7 +14690,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -14696,6 +14699,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -14880,6 +14884,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15149,7 +15154,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -15205,6 +15209,7 @@
|
||||
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
@@ -15318,6 +15323,7 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15331,6 +15337,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -16009,6 +16016,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -16024,7 +16032,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -16139,7 +16147,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -16269,6 +16277,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16278,7 +16287,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -16290,7 +16299,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -13,7 +13,7 @@
|
||||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.3"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface CliArgs {
|
||||
openaiLogging: boolean | undefined;
|
||||
openaiApiKey: string | undefined;
|
||||
openaiBaseUrl: string | undefined;
|
||||
openaiLoggingDir: string | undefined;
|
||||
proxy: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
tavilyApiKey: string | undefined;
|
||||
@@ -317,6 +318,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
description:
|
||||
'Enable logging of OpenAI API calls for debugging and analysis',
|
||||
})
|
||||
.option('openai-logging-dir', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Custom directory path for OpenAI API logs. Overrides settings files.',
|
||||
})
|
||||
.option('openai-api-key', {
|
||||
type: 'string',
|
||||
description: 'OpenAI API key to use for authentication',
|
||||
@@ -764,6 +770,8 @@ export async function loadCliConfig(
|
||||
(typeof argv.openaiLogging === 'undefined'
|
||||
? settings.model?.enableOpenAILogging
|
||||
: argv.openaiLogging) ?? false,
|
||||
openAILoggingDir:
|
||||
argv.openaiLoggingDir || settings.model?.openAILoggingDir,
|
||||
},
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(
|
||||
|
||||
@@ -558,6 +558,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable OpenAI logging.',
|
||||
showInDialog: true,
|
||||
},
|
||||
openAILoggingDir: {
|
||||
type: 'string',
|
||||
label: 'OpenAI Logging Directory',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
generationConfig: {
|
||||
type: 'object',
|
||||
label: 'Generation Configuration',
|
||||
|
||||
@@ -327,6 +327,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
openaiLogging: undefined,
|
||||
openaiApiKey: undefined,
|
||||
openaiBaseUrl: undefined,
|
||||
openaiLoggingDir: undefined,
|
||||
proxy: undefined,
|
||||
includeDirectories: undefined,
|
||||
tavilyApiKey: undefined,
|
||||
|
||||
@@ -1227,4 +1227,28 @@ describe('FileCommandLoader', () => {
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbortError handling', () => {
|
||||
it('should silently ignore AbortError when operation is cancelled', async () => {
|
||||
const userCommandsDir = Storage.getUserCommandsDir();
|
||||
mock({
|
||||
[userCommandsDir]: {
|
||||
'test1.toml': 'prompt = "Prompt 1"',
|
||||
'test2.toml': 'prompt = "Prompt 2"',
|
||||
},
|
||||
});
|
||||
|
||||
const loader = new FileCommandLoader(null);
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
// Start loading and immediately abort
|
||||
const loadPromise = loader.loadCommands(signal);
|
||||
controller.abort();
|
||||
|
||||
// Should not throw or print errors
|
||||
const commands = await loadPromise;
|
||||
expect(commands).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,11 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
// Add all commands without deduplication
|
||||
allCommands.push(...commands);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled)
|
||||
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
const isAbortError =
|
||||
error instanceof Error && error.name === 'AbortError';
|
||||
if (!isEnoent && !isAbortError) {
|
||||
console.error(
|
||||
`[FileCommandLoader] Error loading commands from ${dirInfo.path}:`,
|
||||
error,
|
||||
|
||||
@@ -916,17 +916,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
(result: IdeIntegrationNudgeResult) => {
|
||||
if (result.userSelection === 'yes') {
|
||||
handleSlashCommand('/ide install');
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
true,
|
||||
);
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
} else if (result.userSelection === 'dismiss') {
|
||||
settings.setValue(
|
||||
SettingScope.User,
|
||||
'hasSeenIdeIntegrationNudge',
|
||||
true,
|
||||
);
|
||||
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
|
||||
}
|
||||
setIdePromptAnswered(true);
|
||||
},
|
||||
|
||||
@@ -12,6 +12,12 @@ import type {
|
||||
GeminiChat,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolResult,
|
||||
SubAgentEventEmitter,
|
||||
SubAgentToolCallEvent,
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
@@ -28,6 +34,10 @@ import {
|
||||
getErrorStatus,
|
||||
isWithinRoot,
|
||||
isNodeError,
|
||||
SubAgentEventType,
|
||||
TaskTool,
|
||||
Kind,
|
||||
TodoWriteTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import * as acp from './acp.js';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -403,9 +413,34 @@ class Session {
|
||||
);
|
||||
}
|
||||
|
||||
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
|
||||
const isTodoWriteTool =
|
||||
fc.name === TodoWriteTool.Name || tool.name === TodoWriteTool.Name;
|
||||
|
||||
// Declare subAgentToolEventListeners outside try block for cleanup in catch
|
||||
let subAgentToolEventListeners: Array<() => void> = [];
|
||||
|
||||
try {
|
||||
const invocation = tool.build(args);
|
||||
|
||||
// Detect TaskTool and set up sub-agent tool tracking
|
||||
const isTaskTool = tool.name === TaskTool.Name;
|
||||
|
||||
if (isTaskTool && 'eventEmitter' in invocation) {
|
||||
// Access eventEmitter from TaskTool invocation
|
||||
const taskEventEmitter = (
|
||||
invocation as {
|
||||
eventEmitter: SubAgentEventEmitter;
|
||||
}
|
||||
).eventEmitter;
|
||||
|
||||
// Set up sub-agent tool tracking
|
||||
subAgentToolEventListeners = this.setupSubAgentToolTracking(
|
||||
taskEventEmitter,
|
||||
abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
|
||||
@@ -460,7 +495,8 @@ class Session {
|
||||
throw new Error(`Unexpected: ${resultOutcome}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (!isTodoWriteTool) {
|
||||
// Skip tool_call event for TodoWriteTool
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: callId,
|
||||
@@ -473,14 +509,61 @@ class Session {
|
||||
}
|
||||
|
||||
const toolResult: ToolResult = await invocation.execute(abortSignal);
|
||||
const content = toToolCallContent(toolResult);
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
content: content ? [content] : [],
|
||||
});
|
||||
// Clean up event listeners
|
||||
subAgentToolEventListeners.forEach((cleanup) => cleanup());
|
||||
|
||||
// Handle TodoWriteTool: extract todos and send plan update
|
||||
if (isTodoWriteTool) {
|
||||
// Extract todos from args (initial state)
|
||||
let todos: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}> = [];
|
||||
|
||||
if (Array.isArray(args['todos'])) {
|
||||
todos = args['todos'] as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>;
|
||||
}
|
||||
|
||||
// If returnDisplay has todos (e.g., modified by user), use those instead
|
||||
if (
|
||||
toolResult.returnDisplay &&
|
||||
typeof toolResult.returnDisplay === 'object' &&
|
||||
'type' in toolResult.returnDisplay &&
|
||||
toolResult.returnDisplay.type === 'todo_list' &&
|
||||
'todos' in toolResult.returnDisplay &&
|
||||
Array.isArray(toolResult.returnDisplay.todos)
|
||||
) {
|
||||
todos = toolResult.returnDisplay.todos;
|
||||
}
|
||||
|
||||
// Convert todos to plan entries and send plan update
|
||||
if (todos.length > 0 || Array.isArray(args['todos'])) {
|
||||
const planEntries = convertTodosToPlanEntries(todos);
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'plan',
|
||||
entries: planEntries,
|
||||
});
|
||||
}
|
||||
|
||||
// Skip tool_call_update event for TodoWriteTool
|
||||
// Still log and return function response for LLM
|
||||
} else {
|
||||
// Normal tool handling: send tool_call_update
|
||||
const content = toToolCallContent(toolResult);
|
||||
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: callId,
|
||||
status: 'completed',
|
||||
content: content ? [content] : [],
|
||||
});
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logToolCall(this.config, {
|
||||
@@ -500,6 +583,9 @@ class Session {
|
||||
|
||||
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
|
||||
} catch (e) {
|
||||
// Ensure cleanup on error
|
||||
subAgentToolEventListeners.forEach((cleanup) => cleanup());
|
||||
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
|
||||
await this.sendUpdate({
|
||||
@@ -515,6 +601,300 @@ class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners to track sub-agent tool calls within a TaskTool execution.
|
||||
* Converts subagent tool call events into zedIntegration session updates.
|
||||
*
|
||||
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
|
||||
* @param abortSignal - Signal to abort tracking if parent is cancelled
|
||||
* @returns Array of cleanup functions to remove event listeners
|
||||
*/
|
||||
private setupSubAgentToolTracking(
|
||||
eventEmitter: SubAgentEventEmitter,
|
||||
abortSignal: AbortSignal,
|
||||
): Array<() => void> {
|
||||
const cleanupFunctions: Array<() => void> = [];
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
|
||||
// Track subagent tool call states
|
||||
const subAgentToolStates = new Map<
|
||||
string,
|
||||
{
|
||||
tool?: AnyDeclarativeTool;
|
||||
invocation?: AnyToolInvocation;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Listen for tool call start
|
||||
const onToolCall = (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentToolCallEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
const subAgentTool = toolRegistry.getTool(event.name);
|
||||
let subAgentInvocation: AnyToolInvocation | undefined;
|
||||
let toolKind: acp.ToolKind = 'other';
|
||||
let locations: acp.ToolCallLocation[] = [];
|
||||
|
||||
if (subAgentTool) {
|
||||
try {
|
||||
subAgentInvocation = subAgentTool.build(event.args);
|
||||
toolKind = this.mapToolKind(subAgentTool.kind);
|
||||
locations = subAgentInvocation.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
// If building fails, continue with defaults
|
||||
console.warn(`Failed to build subagent tool ${event.name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Save state for subsequent updates
|
||||
subAgentToolStates.set(event.callId, {
|
||||
tool: subAgentTool,
|
||||
invocation: subAgentInvocation,
|
||||
args: event.args,
|
||||
});
|
||||
|
||||
// Check if this is TodoWriteTool - if so, skip sending tool_call event
|
||||
// Plan update will be sent in onToolResult when we have the final state
|
||||
if (event.name === TodoWriteTool.Name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send tool call start update with rawInput
|
||||
void this.sendUpdate({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: event.callId,
|
||||
status: 'in_progress',
|
||||
title: event.description || event.name,
|
||||
content: [],
|
||||
locations,
|
||||
kind: toolKind,
|
||||
rawInput: event.args,
|
||||
});
|
||||
};
|
||||
|
||||
// Listen for tool call result
|
||||
const onToolResult = (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentToolResultEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
const state = subAgentToolStates.get(event.callId);
|
||||
|
||||
// Check if this is TodoWriteTool - if so, route to plan updates
|
||||
if (event.name === TodoWriteTool.Name) {
|
||||
let todos:
|
||||
| Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>
|
||||
| undefined;
|
||||
|
||||
// Try to extract todos from resultDisplay first (final state)
|
||||
if (event.resultDisplay) {
|
||||
try {
|
||||
// resultDisplay might be a JSON stringified object
|
||||
const parsed =
|
||||
typeof event.resultDisplay === 'string'
|
||||
? JSON.parse(event.resultDisplay)
|
||||
: event.resultDisplay;
|
||||
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'type' in parsed &&
|
||||
parsed.type === 'todo_list' &&
|
||||
'todos' in parsed &&
|
||||
Array.isArray(parsed.todos)
|
||||
) {
|
||||
todos = parsed.todos;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, ignore - resultDisplay might not be JSON
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to args if resultDisplay doesn't have todos
|
||||
if (!todos && state?.args && Array.isArray(state.args['todos'])) {
|
||||
todos = state.args['todos'] as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>;
|
||||
}
|
||||
|
||||
// Send plan update if we have todos
|
||||
if (todos) {
|
||||
const planEntries = convertTodosToPlanEntries(todos);
|
||||
void this.sendUpdate({
|
||||
sessionUpdate: 'plan',
|
||||
entries: planEntries,
|
||||
});
|
||||
}
|
||||
|
||||
// Skip sending tool_call_update event for TodoWriteTool
|
||||
// Clean up state
|
||||
subAgentToolStates.delete(event.callId);
|
||||
return;
|
||||
}
|
||||
|
||||
let content: acp.ToolCallContent[] = [];
|
||||
|
||||
// If there's a result display, try to convert to ToolCallContent
|
||||
if (event.resultDisplay && state?.invocation) {
|
||||
// resultDisplay is typically a string
|
||||
if (typeof event.resultDisplay === 'string') {
|
||||
content = [
|
||||
{
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: event.resultDisplay,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Send tool call completion update
|
||||
void this.sendUpdate({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: event.callId,
|
||||
status: event.success ? 'completed' : 'failed',
|
||||
content: content.length > 0 ? content : [],
|
||||
title: state?.invocation?.getDescription() ?? event.name,
|
||||
kind: state?.tool ? this.mapToolKind(state.tool.kind) : null,
|
||||
locations:
|
||||
state?.invocation?.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
})) ?? null,
|
||||
rawInput: state?.args,
|
||||
});
|
||||
|
||||
// Clean up state
|
||||
subAgentToolStates.delete(event.callId);
|
||||
};
|
||||
|
||||
// Listen for permission requests
|
||||
const onToolWaitingApproval = async (...args: unknown[]) => {
|
||||
const event = args[0] as SubAgentApprovalRequestEvent;
|
||||
if (abortSignal.aborted) return;
|
||||
|
||||
const state = subAgentToolStates.get(event.callId);
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
|
||||
// Handle different confirmation types
|
||||
if (event.confirmationDetails.type === 'edit') {
|
||||
const editDetails = event.confirmationDetails as unknown as {
|
||||
type: 'edit';
|
||||
fileName: string;
|
||||
originalContent: string | null;
|
||||
newContent: string;
|
||||
};
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: editDetails.fileName,
|
||||
oldText: editDetails.originalContent ?? '',
|
||||
newText: editDetails.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
// Build permission request options from confirmation details
|
||||
// event.confirmationDetails already contains all fields except onConfirm,
|
||||
// which we add here to satisfy the type requirement for toPermissionOptions
|
||||
const fullConfirmationDetails = {
|
||||
...event.confirmationDetails,
|
||||
onConfirm: async () => {
|
||||
// This is a placeholder - the actual response is handled via event.respond
|
||||
},
|
||||
} as unknown as ToolCallConfirmationDetails;
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
sessionId: this.id,
|
||||
options: toPermissionOptions(fullConfirmationDetails),
|
||||
toolCall: {
|
||||
toolCallId: event.callId,
|
||||
status: 'pending',
|
||||
title: event.description || event.name,
|
||||
content,
|
||||
locations:
|
||||
state?.invocation?.toolLocations().map((loc) => ({
|
||||
path: loc.path,
|
||||
line: loc.line ?? null,
|
||||
})) ?? [],
|
||||
kind: state?.tool ? this.mapToolKind(state.tool.kind) : 'other',
|
||||
rawInput: state?.args,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// Request permission from zed client
|
||||
const output = await this.client.requestPermission(params);
|
||||
const outcome =
|
||||
output.outcome.outcome === 'cancelled'
|
||||
? ToolConfirmationOutcome.Cancel
|
||||
: z
|
||||
.nativeEnum(ToolConfirmationOutcome)
|
||||
.parse(output.outcome.optionId);
|
||||
|
||||
// Respond to subagent with the outcome
|
||||
await event.respond(outcome);
|
||||
} catch (error) {
|
||||
// If permission request fails, cancel the tool call
|
||||
console.error(
|
||||
`Permission request failed for subagent tool ${event.name}:`,
|
||||
error,
|
||||
);
|
||||
await event.respond(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
};
|
||||
|
||||
// Register event listeners
|
||||
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.on(
|
||||
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
||||
onToolWaitingApproval,
|
||||
);
|
||||
|
||||
// Return cleanup functions
|
||||
cleanupFunctions.push(() => {
|
||||
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
|
||||
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
|
||||
eventEmitter.off(
|
||||
SubAgentEventType.TOOL_WAITING_APPROVAL,
|
||||
onToolWaitingApproval,
|
||||
);
|
||||
});
|
||||
|
||||
return cleanupFunctions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps core Tool Kind enum to ACP ToolKind string literals.
|
||||
*
|
||||
* @param kind - The core Kind enum value
|
||||
* @returns The corresponding ACP ToolKind string literal
|
||||
*/
|
||||
private mapToolKind(kind: Kind): acp.ToolKind {
|
||||
const kindMap: Record<Kind, acp.ToolKind> = {
|
||||
[Kind.Read]: 'read',
|
||||
[Kind.Edit]: 'edit',
|
||||
[Kind.Delete]: 'delete',
|
||||
[Kind.Move]: 'move',
|
||||
[Kind.Search]: 'search',
|
||||
[Kind.Execute]: 'execute',
|
||||
[Kind.Think]: 'think',
|
||||
[Kind.Fetch]: 'fetch',
|
||||
[Kind.Other]: 'other',
|
||||
};
|
||||
return kindMap[kind] ?? 'other';
|
||||
}
|
||||
|
||||
async #resolvePrompt(
|
||||
message: acp.ContentBlock[],
|
||||
abortSignal: AbortSignal,
|
||||
@@ -859,6 +1239,27 @@ class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts todo items to plan entries format for zed integration.
|
||||
* Maps todo status to plan status and assigns a default priority.
|
||||
*
|
||||
* @param todos - Array of todo items with id, content, and status
|
||||
* @returns Array of plan entries with content, priority, and status
|
||||
*/
|
||||
function convertTodosToPlanEntries(
|
||||
todos: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>,
|
||||
): acp.PlanEntry[] {
|
||||
return todos.map((todo) => ({
|
||||
content: todo.content,
|
||||
priority: 'medium' as const, // Default priority since todos don't have priority
|
||||
status: todo.status,
|
||||
}));
|
||||
}
|
||||
|
||||
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
if (toolResult.error?.message) {
|
||||
throw new Error(toolResult.error.message);
|
||||
@@ -870,26 +1271,6 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: toolResult.returnDisplay },
|
||||
};
|
||||
} else if (
|
||||
'type' in toolResult.returnDisplay &&
|
||||
toolResult.returnDisplay.type === 'todo_list'
|
||||
) {
|
||||
// Handle TodoResultDisplay - convert to text representation
|
||||
const todoText = toolResult.returnDisplay.todos
|
||||
.map((todo) => {
|
||||
const statusIcon = {
|
||||
pending: '○',
|
||||
in_progress: '◐',
|
||||
completed: '●',
|
||||
}[todo.status];
|
||||
return `${statusIcon} ${todo.content}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: todoText },
|
||||
};
|
||||
} else if (
|
||||
'type' in toolResult.returnDisplay &&
|
||||
toolResult.returnDisplay.type === 'plan_summary'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -21,6 +21,9 @@ vi.mock('../../telemetry/loggers.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/openaiLogger.js', () => ({
|
||||
OpenAILogger: vi.fn().mockImplementation(() => ({
|
||||
logInteraction: vi.fn(),
|
||||
})),
|
||||
openaiLogger: {
|
||||
logInteraction: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -58,6 +58,7 @@ export type ContentGeneratorConfig = {
|
||||
vertexai?: boolean;
|
||||
authType?: AuthType | undefined;
|
||||
enableOpenAILogging?: boolean;
|
||||
openAILoggingDir?: string;
|
||||
// Timeout configuration in milliseconds
|
||||
timeout?: number;
|
||||
// Maximum retries for failed requests
|
||||
|
||||
@@ -32,6 +32,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
||||
telemetryService: new DefaultTelemetryService(
|
||||
cliConfig,
|
||||
contentGeneratorConfig.enableOpenAILogging,
|
||||
contentGeneratorConfig.openAILoggingDir,
|
||||
),
|
||||
errorHandler: new EnhancedErrorHandler(
|
||||
(error: unknown, request: GenerateContentParameters) =>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import type { Config } from '../../config/config.js';
|
||||
import { logApiError, logApiResponse } from '../../telemetry/loggers.js';
|
||||
import { ApiErrorEvent, ApiResponseEvent } from '../../telemetry/types.js';
|
||||
import { openaiLogger } from '../../utils/openaiLogger.js';
|
||||
import { OpenAILogger } from '../../utils/openaiLogger.js';
|
||||
import type { GenerateContentResponse } from '@google/genai';
|
||||
import type OpenAI from 'openai';
|
||||
|
||||
@@ -43,10 +43,17 @@ export interface TelemetryService {
|
||||
}
|
||||
|
||||
export class DefaultTelemetryService implements TelemetryService {
|
||||
private logger: OpenAILogger;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private enableOpenAILogging: boolean = false,
|
||||
) {}
|
||||
openAILoggingDir?: string,
|
||||
) {
|
||||
// Always create a new logger instance to ensure correct working directory
|
||||
// If no custom directory is provided, undefined will use the default path
|
||||
this.logger = new OpenAILogger(openAILoggingDir);
|
||||
}
|
||||
|
||||
async logSuccess(
|
||||
context: RequestContext,
|
||||
@@ -68,7 +75,7 @@ export class DefaultTelemetryService implements TelemetryService {
|
||||
|
||||
// Log interaction if enabled
|
||||
if (this.enableOpenAILogging && openaiRequest && openaiResponse) {
|
||||
await openaiLogger.logInteraction(openaiRequest, openaiResponse);
|
||||
await this.logger.logInteraction(openaiRequest, openaiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +104,7 @@ export class DefaultTelemetryService implements TelemetryService {
|
||||
|
||||
// Log error interaction if enabled
|
||||
if (this.enableOpenAILogging && openaiRequest) {
|
||||
await openaiLogger.logInteraction(
|
||||
await this.logger.logInteraction(
|
||||
openaiRequest,
|
||||
undefined,
|
||||
error as Error,
|
||||
@@ -137,7 +144,7 @@ export class DefaultTelemetryService implements TelemetryService {
|
||||
openaiChunks.length > 0
|
||||
) {
|
||||
const combinedResponse = this.combineOpenAIChunksForLogging(openaiChunks);
|
||||
await openaiLogger.logInteraction(openaiRequest, combinedResponse);
|
||||
await this.logger.logInteraction(openaiRequest, combinedResponse);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,12 @@ describe('normalize', () => {
|
||||
expect(normalize('qwen-vl-max-latest')).toBe('qwen-vl-max-latest');
|
||||
});
|
||||
|
||||
it('should preserve date suffixes for Kimi K2 models', () => {
|
||||
expect(normalize('kimi-k2-0905-preview')).toBe('kimi-k2-0905');
|
||||
expect(normalize('kimi-k2-0711-preview')).toBe('kimi-k2-0711');
|
||||
expect(normalize('kimi-k2-turbo-preview')).toBe('kimi-k2-turbo');
|
||||
});
|
||||
|
||||
it('should remove date like suffixes', () => {
|
||||
expect(normalize('deepseek-r1-0528')).toBe('deepseek-r1');
|
||||
});
|
||||
@@ -213,7 +219,7 @@ describe('tokenLimit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other models', () => {
|
||||
describe('DeepSeek', () => {
|
||||
it('should return the correct limit for deepseek-r1', () => {
|
||||
expect(tokenLimit('deepseek-r1')).toBe(131072);
|
||||
});
|
||||
@@ -226,9 +232,27 @@ describe('tokenLimit', () => {
|
||||
it('should return the correct limit for deepseek-v3.2', () => {
|
||||
expect(tokenLimit('deepseek-v3.2-exp')).toBe(131072);
|
||||
});
|
||||
it('should return the correct limit for kimi-k2-instruct', () => {
|
||||
expect(tokenLimit('kimi-k2-instruct')).toBe(131072);
|
||||
});
|
||||
|
||||
describe('Moonshot Kimi', () => {
|
||||
it('should return the correct limit for kimi-k2-0905-preview', () => {
|
||||
expect(tokenLimit('kimi-k2-0905-preview')).toBe(262144); // 256K
|
||||
expect(tokenLimit('kimi-k2-0905')).toBe(262144);
|
||||
});
|
||||
it('should return the correct limit for kimi-k2-turbo-preview', () => {
|
||||
expect(tokenLimit('kimi-k2-turbo-preview')).toBe(262144); // 256K
|
||||
expect(tokenLimit('kimi-k2-turbo')).toBe(262144);
|
||||
});
|
||||
it('should return the correct limit for kimi-k2-0711-preview', () => {
|
||||
expect(tokenLimit('kimi-k2-0711-preview')).toBe(131072); // 128K
|
||||
expect(tokenLimit('kimi-k2-0711')).toBe(131072);
|
||||
});
|
||||
it('should return the correct limit for kimi-k2-instruct', () => {
|
||||
expect(tokenLimit('kimi-k2-instruct')).toBe(131072); // 128K
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other models', () => {
|
||||
it('should return the correct limit for gpt-oss', () => {
|
||||
expect(tokenLimit('gpt-oss')).toBe(131072);
|
||||
});
|
||||
|
||||
@@ -47,8 +47,13 @@ export function normalize(model: string): string {
|
||||
// remove trailing build / date / revision suffixes:
|
||||
// - dates (e.g., -20250219), -v1, version numbers, 'latest', 'preview' etc.
|
||||
s = s.replace(/-preview/g, '');
|
||||
// Special handling for Qwen model names that include "-latest" as part of the model name
|
||||
if (!s.match(/^qwen-(?:plus|flash|vl-max)-latest$/)) {
|
||||
// Special handling for model names that include date/version as part of the model identifier
|
||||
// - Qwen models: qwen-plus-latest, qwen-flash-latest, qwen-vl-max-latest
|
||||
// - Kimi models: kimi-k2-0905, kimi-k2-0711, etc. (keep date for version distinction)
|
||||
if (
|
||||
!s.match(/^qwen-(?:plus|flash|vl-max)-latest$/) &&
|
||||
!s.match(/^kimi-k2-\d{4}$/)
|
||||
) {
|
||||
// Regex breakdown:
|
||||
// -(?:...)$ - Non-capturing group for suffixes at the end of the string
|
||||
// The following patterns are matched within the group:
|
||||
@@ -165,9 +170,16 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [
|
||||
[/^deepseek-v3(?:\.\d+)?(?:-.*)?$/, LIMITS['128k']],
|
||||
|
||||
// -------------------
|
||||
// GPT-OSS / Kimi / Llama & Mistral examples
|
||||
// Moonshot / Kimi
|
||||
// -------------------
|
||||
[/^kimi-k2-0905$/, LIMITS['256k']], // Kimi-k2-0905-preview: 256K context
|
||||
[/^kimi-k2-turbo.*$/, LIMITS['256k']], // Kimi-k2-turbo-preview: 256K context
|
||||
[/^kimi-k2-0711$/, LIMITS['128k']], // Kimi-k2-0711-preview: 128K context
|
||||
[/^kimi-k2-instruct.*$/, LIMITS['128k']], // Kimi-k2-instruct: 128K context
|
||||
|
||||
// -------------------
|
||||
// GPT-OSS / Llama & Mistral examples
|
||||
// -------------------
|
||||
[/^kimi-k2-instruct.*$/, LIMITS['128k']],
|
||||
[/^gpt-oss.*$/, LIMITS['128k']],
|
||||
[/^llama-4-scout.*$/, LIMITS['10m']],
|
||||
[/^mistral-large-2.*$/, LIMITS['128k']],
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('IdeClient', () => {
|
||||
'utf8',
|
||||
);
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
new URL('http://localhost:8080/mcp'),
|
||||
new URL('http://127.0.0.1:8080/mcp'),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
|
||||
@@ -181,7 +181,7 @@ describe('IdeClient', () => {
|
||||
await ideClient.connect();
|
||||
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
new URL('http://localhost:9090/mcp'),
|
||||
new URL('http://127.0.0.1:9090/mcp'),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(mockHttpTransport);
|
||||
@@ -230,7 +230,7 @@ describe('IdeClient', () => {
|
||||
await ideClient.connect();
|
||||
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
new URL('http://localhost:8080/mcp'),
|
||||
new URL('http://127.0.0.1:8080/mcp'),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(ideClient.getConnectionStatus().status).toBe(
|
||||
@@ -665,7 +665,7 @@ describe('IdeClient', () => {
|
||||
await ideClient.connect();
|
||||
|
||||
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
|
||||
new URL('http://localhost:8080/mcp'),
|
||||
new URL('http://127.0.0.1:8080/mcp'),
|
||||
expect.objectContaining({
|
||||
requestInit: {
|
||||
headers: {
|
||||
|
||||
@@ -667,10 +667,10 @@ export class IdeClient {
|
||||
}
|
||||
|
||||
private createProxyAwareFetch() {
|
||||
// ignore proxy for 'localhost' by deafult to allow connecting to the ide mcp server
|
||||
// ignore proxy for '127.0.0.1' by deafult to allow connecting to the ide mcp server
|
||||
const existingNoProxy = process.env['NO_PROXY'] || '';
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
noProxy: [existingNoProxy, 'localhost'].filter(Boolean).join(','),
|
||||
noProxy: [existingNoProxy, '127.0.0.1'].filter(Boolean).join(','),
|
||||
});
|
||||
const undiciPromise = import('undici');
|
||||
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
||||
@@ -851,5 +851,5 @@ export class IdeClient {
|
||||
function getIdeServerHost() {
|
||||
const isInContainer =
|
||||
fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv');
|
||||
return isInContainer ? 'host.docker.internal' : 'localhost';
|
||||
return isInContainer ? 'host.docker.internal' : '127.0.0.1';
|
||||
}
|
||||
|
||||
@@ -112,14 +112,19 @@ describe('ide-installer', () => {
|
||||
platform: 'linux',
|
||||
});
|
||||
await installer.install();
|
||||
|
||||
// Note: The implementation uses process.platform, not the mocked platform
|
||||
const isActuallyWindows = process.platform === 'win32';
|
||||
const expectedCommand = isActuallyWindows ? '"code"' : 'code';
|
||||
|
||||
expect(child_process.spawnSync).toHaveBeenCalledWith(
|
||||
'code',
|
||||
expectedCommand,
|
||||
[
|
||||
'--install-extension',
|
||||
'qwenlm.qwen-code-vscode-ide-companion',
|
||||
'--force',
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
{ stdio: 'pipe', shell: isActuallyWindows },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -117,15 +117,16 @@ class VsCodeInstaller implements IdeInstaller {
|
||||
};
|
||||
}
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
try {
|
||||
const result = child_process.spawnSync(
|
||||
commandPath,
|
||||
isWindows ? `"${commandPath}"` : commandPath,
|
||||
[
|
||||
'--install-extension',
|
||||
'qwenlm.qwen-code-vscode-ide-companion',
|
||||
'--force',
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
{ stdio: 'pipe', shell: isWindows },
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
|
||||
@@ -102,6 +102,8 @@ export * from './tools/web-search/index.js';
|
||||
export * from './tools/read-many-files.js';
|
||||
export * from './tools/mcp-client.js';
|
||||
export * from './tools/mcp-tool.js';
|
||||
export * from './tools/task.js';
|
||||
export * from './tools/todoWrite.js';
|
||||
|
||||
// MCP OAuth
|
||||
export { MCPOAuthProvider } from './mcp/oauth-provider.js';
|
||||
|
||||
@@ -62,9 +62,10 @@ export type {
|
||||
SubAgentToolResultEvent,
|
||||
SubAgentFinishEvent,
|
||||
SubAgentErrorEvent,
|
||||
SubAgentApprovalRequestEvent,
|
||||
} from './subagent-events.js';
|
||||
|
||||
export { SubAgentEventEmitter } from './subagent-events.js';
|
||||
export { SubAgentEventEmitter, SubAgentEventType } from './subagent-events.js';
|
||||
|
||||
// Statistics and formatting
|
||||
export type {
|
||||
|
||||
@@ -88,17 +88,6 @@ describe('GlobTool', () => {
|
||||
expect(result.returnDisplay).toBe('Found 2 matching file(s)');
|
||||
});
|
||||
|
||||
it('should find files case-sensitively when case_sensitive is true', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.txt', case_sensitive: true };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain('Found 1 file(s)');
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
|
||||
expect(result.llmContent).not.toContain(
|
||||
path.join(tempRootDir, 'FileB.TXT'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should find files case-insensitively by default (pattern: *.TXT)', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.TXT' };
|
||||
const invocation = globTool.build(params);
|
||||
@@ -108,18 +97,6 @@ describe('GlobTool', () => {
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
|
||||
});
|
||||
|
||||
it('should find files case-insensitively when case_sensitive is false (pattern: *.TXT)', async () => {
|
||||
const params: GlobToolParams = {
|
||||
pattern: '*.TXT',
|
||||
case_sensitive: false,
|
||||
};
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain('Found 2 file(s)');
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt'));
|
||||
expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT'));
|
||||
});
|
||||
|
||||
it('should find files using a pattern that includes a subdirectory', async () => {
|
||||
const params: GlobToolParams = { pattern: 'sub/*.md' };
|
||||
const invocation = globTool.build(params);
|
||||
@@ -207,7 +184,7 @@ describe('GlobTool', () => {
|
||||
const filesListed = llmContent
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.slice(2)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -220,14 +197,13 @@ describe('GlobTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a PATH_NOT_IN_WORKSPACE error if path is outside workspace', async () => {
|
||||
it('should return error if path is outside workspace', async () => {
|
||||
// Bypassing validation to test execute method directly
|
||||
vi.spyOn(globTool, 'validateToolParams').mockReturnValue(null);
|
||||
const params: GlobToolParams = { pattern: '*.txt', path: '/etc' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.error?.type).toBe(ToolErrorType.PATH_NOT_IN_WORKSPACE);
|
||||
expect(result.returnDisplay).toBe('Path is not within workspace');
|
||||
expect(result.returnDisplay).toBe('Error: Path is not within workspace');
|
||||
});
|
||||
|
||||
it('should return a GLOB_EXECUTION_ERROR on glob failure', async () => {
|
||||
@@ -255,15 +231,6 @@ describe('GlobTool', () => {
|
||||
expect(globTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid parameters (pattern, path, and case_sensitive)', () => {
|
||||
const params: GlobToolParams = {
|
||||
pattern: '*.js',
|
||||
path: 'sub',
|
||||
case_sensitive: true,
|
||||
};
|
||||
expect(globTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error if pattern is missing (schema validation)', () => {
|
||||
// Need to correctly define this as an object without pattern
|
||||
const params = { path: '.' };
|
||||
@@ -297,16 +264,6 @@ describe('GlobTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if case_sensitive is provided but is not a boolean', () => {
|
||||
const params = {
|
||||
pattern: '*.ts',
|
||||
case_sensitive: 'true',
|
||||
} as unknown as GlobToolParams; // Force incorrect type
|
||||
expect(globTool.validateToolParams(params)).toBe(
|
||||
'params/case_sensitive must be boolean',
|
||||
);
|
||||
});
|
||||
|
||||
it("should return error if search path resolves outside the tool's root directory", () => {
|
||||
// Create a globTool instance specifically for this test, with a deeper root
|
||||
tempRootDir = path.join(tempRootDir, 'sub');
|
||||
@@ -319,7 +276,7 @@ describe('GlobTool', () => {
|
||||
path: '../../../../../../../../../../tmp', // Definitely outside
|
||||
};
|
||||
expect(specificGlobTool.validateToolParams(paramsOutside)).toContain(
|
||||
'resolves outside the allowed workspace directories',
|
||||
'Path is not within workspace',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -329,14 +286,14 @@ describe('GlobTool', () => {
|
||||
path: 'nonexistent_subdir',
|
||||
};
|
||||
expect(globTool.validateToolParams(params)).toContain(
|
||||
'Search path does not exist',
|
||||
'Path does not exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if specified search path is a file, not a directory', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.txt', path: 'fileA.txt' };
|
||||
expect(globTool.validateToolParams(params)).toContain(
|
||||
'Search path is not a directory',
|
||||
'Path is not a directory',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -348,20 +305,10 @@ describe('GlobTool', () => {
|
||||
|
||||
expect(globTool.validateToolParams(validPath)).toBeNull();
|
||||
expect(globTool.validateToolParams(invalidPath)).toContain(
|
||||
'resolves outside the allowed workspace directories',
|
||||
'Path is not within workspace',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide clear error messages when path is outside workspace', () => {
|
||||
const invalidPath = { pattern: '*.ts', path: '/etc' };
|
||||
const error = globTool.validateToolParams(invalidPath);
|
||||
|
||||
expect(error).toContain(
|
||||
'resolves outside the allowed workspace directories',
|
||||
);
|
||||
expect(error).toContain(tempRootDir);
|
||||
});
|
||||
|
||||
it('should work with paths in workspace subdirectories', async () => {
|
||||
const params: GlobToolParams = { pattern: '*.md', path: 'sub' };
|
||||
const invocation = globTool.build(params);
|
||||
@@ -417,47 +364,123 @@ describe('GlobTool', () => {
|
||||
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, b.notignored.txt
|
||||
expect(result.llmContent).not.toContain('a.qwenignored.txt');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not respect .gitignore when respect_git_ignore is false', async () => {
|
||||
await fs.writeFile(path.join(tempRootDir, '.gitignore'), '*.ignored.txt');
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'a.ignored.txt'),
|
||||
'ignored content',
|
||||
);
|
||||
describe('file count truncation', () => {
|
||||
it('should truncate results when more than 100 files are found', async () => {
|
||||
// Create 150 test files
|
||||
for (let i = 1; i <= 150; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `file${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params: GlobToolParams = {
|
||||
pattern: '*.txt',
|
||||
respect_git_ignore: false,
|
||||
};
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
const llmContent = partListUnionToString(result.llmContent);
|
||||
|
||||
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, a.ignored.txt
|
||||
expect(result.llmContent).toContain('a.ignored.txt');
|
||||
// Should report all 150 files found
|
||||
expect(llmContent).toContain('Found 150 file(s)');
|
||||
|
||||
// Should include truncation notice
|
||||
expect(llmContent).toContain('[50 files truncated] ...');
|
||||
|
||||
// Count the number of .trunctest files mentioned in the output
|
||||
const fileMatches = llmContent.match(/file\d+\.trunctest/g);
|
||||
expect(fileMatches).toBeDefined();
|
||||
expect(fileMatches?.length).toBe(100);
|
||||
|
||||
// returnDisplay should indicate truncation
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Found 150 matching file(s) (truncated)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not respect .qwenignore when respect_qwen_ignore is false', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, '.qwenignore'),
|
||||
'*.qwenignored.txt',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'a.qwenignored.txt'),
|
||||
'ignored content',
|
||||
);
|
||||
it('should not truncate when exactly 100 files are found', async () => {
|
||||
// Create exactly 100 test files
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `exact${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Recreate the tool to pick up the new .qwenignore file
|
||||
globTool = new GlobTool(mockConfig);
|
||||
|
||||
const params: GlobToolParams = {
|
||||
pattern: '*.txt',
|
||||
respect_qwen_ignore: false,
|
||||
};
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 3 file(s)'); // fileA.txt, FileB.TXT, a.qwenignored.txt
|
||||
expect(result.llmContent).toContain('a.qwenignored.txt');
|
||||
// Should report all 100 files found
|
||||
expect(result.llmContent).toContain('Found 100 file(s)');
|
||||
|
||||
// Should NOT include truncation notice
|
||||
expect(result.llmContent).not.toContain('truncated');
|
||||
|
||||
// Should show all 100 files
|
||||
expect(result.llmContent).toContain('exact1.trunctest');
|
||||
expect(result.llmContent).toContain('exact100.trunctest');
|
||||
|
||||
// returnDisplay should NOT indicate truncation
|
||||
expect(result.returnDisplay).toBe('Found 100 matching file(s)');
|
||||
});
|
||||
|
||||
it('should not truncate when fewer than 100 files are found', async () => {
|
||||
// Create 50 test files
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `small${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should report all 50 files found
|
||||
expect(result.llmContent).toContain('Found 50 file(s)');
|
||||
|
||||
// Should NOT include truncation notice
|
||||
expect(result.llmContent).not.toContain('truncated');
|
||||
|
||||
// returnDisplay should NOT indicate truncation
|
||||
expect(result.returnDisplay).toBe('Found 50 matching file(s)');
|
||||
});
|
||||
|
||||
it('should use correct singular/plural in truncation message for 1 file truncated', async () => {
|
||||
// Create 101 test files (will truncate 1 file)
|
||||
for (let i = 1; i <= 101; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `singular${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should use singular "file" for 1 truncated file
|
||||
expect(result.llmContent).toContain('[1 file truncated] ...');
|
||||
expect(result.llmContent).not.toContain('[1 files truncated]');
|
||||
});
|
||||
|
||||
it('should use correct plural in truncation message for multiple files truncated', async () => {
|
||||
// Create 105 test files (will truncate 5 files)
|
||||
for (let i = 1; i <= 105; i++) {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, `plural${i}.trunctest`),
|
||||
`content${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
const params: GlobToolParams = { pattern: '*.trunctest' };
|
||||
const invocation = globTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should use plural "files" for multiple truncated files
|
||||
expect(result.llmContent).toContain('[5 files truncated] ...');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,17 @@ import { glob, escape } from 'glob';
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames } from './tool-names.js';
|
||||
import { shortenPath, makeRelative } from '../utils/paths.js';
|
||||
import { resolveAndValidatePath } from '../utils/paths.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
import {
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
type FileFilteringOptions,
|
||||
} from '../config/constants.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
|
||||
const MAX_FILE_COUNT = 100;
|
||||
|
||||
// Subset of 'Path' interface provided by 'glob' that we can implement for testing
|
||||
export interface GlobPath {
|
||||
@@ -64,118 +71,68 @@ export interface GlobToolParams {
|
||||
* The directory to search in (optional, defaults to current directory)
|
||||
*/
|
||||
path?: string;
|
||||
|
||||
/**
|
||||
* Whether the search should be case-sensitive (optional, defaults to false)
|
||||
*/
|
||||
case_sensitive?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to respect .gitignore patterns (optional, defaults to true)
|
||||
*/
|
||||
respect_git_ignore?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to respect .qwenignore patterns (optional, defaults to true)
|
||||
*/
|
||||
respect_qwen_ignore?: boolean;
|
||||
}
|
||||
|
||||
class GlobToolInvocation extends BaseToolInvocation<
|
||||
GlobToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
private fileService: FileDiscoveryService;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
params: GlobToolParams,
|
||||
) {
|
||||
super(params);
|
||||
this.fileService = config.getFileService();
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
let description = `'${this.params.pattern}'`;
|
||||
if (this.params.path) {
|
||||
const searchDir = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.path || '.',
|
||||
);
|
||||
const relativePath = makeRelative(searchDir, this.config.getTargetDir());
|
||||
description += ` within ${shortenPath(relativePath)}`;
|
||||
description += ` in path '${this.params.path}'`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
try {
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const workspaceDirectories = workspaceContext.getDirectories();
|
||||
// Default to target directory if no path is provided
|
||||
const searchDirAbs = resolveAndValidatePath(
|
||||
this.config,
|
||||
this.params.path,
|
||||
);
|
||||
const searchLocationDescription = this.params.path
|
||||
? `within ${searchDirAbs}`
|
||||
: `in the workspace directory`;
|
||||
|
||||
// If a specific path is provided, resolve it and check if it's within workspace
|
||||
let searchDirectories: readonly string[];
|
||||
if (this.params.path) {
|
||||
const searchDirAbsolute = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.path,
|
||||
);
|
||||
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
|
||||
const rawError = `Error: Path "${this.params.path}" is not within any workspace directory`;
|
||||
return {
|
||||
llmContent: rawError,
|
||||
returnDisplay: `Path is not within workspace`,
|
||||
error: {
|
||||
message: rawError,
|
||||
type: ToolErrorType.PATH_NOT_IN_WORKSPACE,
|
||||
},
|
||||
};
|
||||
}
|
||||
searchDirectories = [searchDirAbsolute];
|
||||
} else {
|
||||
// Search across all workspace directories
|
||||
searchDirectories = workspaceDirectories;
|
||||
// Collect entries from the search directory
|
||||
let pattern = this.params.pattern;
|
||||
const fullPath = path.join(searchDirAbs, pattern);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
pattern = escape(pattern);
|
||||
}
|
||||
|
||||
// Get centralized file discovery service
|
||||
const fileDiscovery = this.config.getFileService();
|
||||
|
||||
// Collect entries from all search directories
|
||||
const allEntries: GlobPath[] = [];
|
||||
for (const searchDir of searchDirectories) {
|
||||
let pattern = this.params.pattern;
|
||||
const fullPath = path.join(searchDir, pattern);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
pattern = escape(pattern);
|
||||
}
|
||||
|
||||
const entries = (await glob(pattern, {
|
||||
cwd: searchDir,
|
||||
withFileTypes: true,
|
||||
nodir: true,
|
||||
stat: true,
|
||||
nocase: !this.params.case_sensitive,
|
||||
dot: true,
|
||||
ignore: this.config.getFileExclusions().getGlobExcludes(),
|
||||
follow: false,
|
||||
signal,
|
||||
})) as GlobPath[];
|
||||
|
||||
allEntries.push(...entries);
|
||||
}
|
||||
const allEntries = (await glob(pattern, {
|
||||
cwd: searchDirAbs,
|
||||
withFileTypes: true,
|
||||
nodir: true,
|
||||
stat: true,
|
||||
nocase: true,
|
||||
dot: true,
|
||||
follow: false,
|
||||
signal,
|
||||
})) as GlobPath[];
|
||||
|
||||
const relativePaths = allEntries.map((p) =>
|
||||
path.relative(this.config.getTargetDir(), p.fullpath()),
|
||||
);
|
||||
|
||||
const { filteredPaths, gitIgnoredCount, qwenIgnoredCount } =
|
||||
fileDiscovery.filterFilesWithReport(relativePaths, {
|
||||
respectGitIgnore:
|
||||
this.params?.respect_git_ignore ??
|
||||
this.config.getFileFilteringOptions().respectGitIgnore ??
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
respectQwenIgnore:
|
||||
this.params?.respect_qwen_ignore ??
|
||||
this.config.getFileFilteringOptions().respectQwenIgnore ??
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore,
|
||||
});
|
||||
const { filteredPaths } = this.fileService.filterFilesWithReport(
|
||||
relativePaths,
|
||||
this.getFileFilteringOptions(),
|
||||
);
|
||||
|
||||
const filteredAbsolutePaths = new Set(
|
||||
filteredPaths.map((p) => path.resolve(this.config.getTargetDir(), p)),
|
||||
@@ -186,20 +143,8 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
);
|
||||
|
||||
if (!filteredEntries || filteredEntries.length === 0) {
|
||||
let message = `No files found matching pattern "${this.params.pattern}"`;
|
||||
if (searchDirectories.length === 1) {
|
||||
message += ` within ${searchDirectories[0]}`;
|
||||
} else {
|
||||
message += ` within ${searchDirectories.length} workspace directories`;
|
||||
}
|
||||
if (gitIgnoredCount > 0) {
|
||||
message += ` (${gitIgnoredCount} files were git-ignored)`;
|
||||
}
|
||||
if (qwenIgnoredCount > 0) {
|
||||
message += ` (${qwenIgnoredCount} files were qwen-ignored)`;
|
||||
}
|
||||
return {
|
||||
llmContent: message,
|
||||
llmContent: `No files found matching pattern "${this.params.pattern}" ${searchLocationDescription}`,
|
||||
returnDisplay: `No files found`,
|
||||
};
|
||||
}
|
||||
@@ -215,29 +160,32 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
oneDayInMs,
|
||||
);
|
||||
|
||||
const sortedAbsolutePaths = sortedEntries.map((entry) =>
|
||||
const totalFileCount = sortedEntries.length;
|
||||
const truncated = totalFileCount > MAX_FILE_COUNT;
|
||||
|
||||
// Limit to MAX_FILE_COUNT if needed
|
||||
const entriesToShow = truncated
|
||||
? sortedEntries.slice(0, MAX_FILE_COUNT)
|
||||
: sortedEntries;
|
||||
|
||||
const sortedAbsolutePaths = entriesToShow.map((entry) =>
|
||||
entry.fullpath(),
|
||||
);
|
||||
const fileListDescription = sortedAbsolutePaths.join('\n');
|
||||
const fileCount = sortedAbsolutePaths.length;
|
||||
|
||||
let resultMessage = `Found ${fileCount} file(s) matching "${this.params.pattern}"`;
|
||||
if (searchDirectories.length === 1) {
|
||||
resultMessage += ` within ${searchDirectories[0]}`;
|
||||
} else {
|
||||
resultMessage += ` across ${searchDirectories.length} workspace directories`;
|
||||
let resultMessage = `Found ${totalFileCount} file(s) matching "${this.params.pattern}" ${searchLocationDescription}`;
|
||||
resultMessage += `, sorted by modification time (newest first):\n---\n${fileListDescription}`;
|
||||
|
||||
// Add truncation notice if needed
|
||||
if (truncated) {
|
||||
const omittedFiles = totalFileCount - MAX_FILE_COUNT;
|
||||
const fileTerm = omittedFiles === 1 ? 'file' : 'files';
|
||||
resultMessage += `\n---\n[${omittedFiles} ${fileTerm} truncated] ...`;
|
||||
}
|
||||
if (gitIgnoredCount > 0) {
|
||||
resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`;
|
||||
}
|
||||
if (qwenIgnoredCount > 0) {
|
||||
resultMessage += ` (${qwenIgnoredCount} additional files were qwen-ignored)`;
|
||||
}
|
||||
resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`;
|
||||
|
||||
return {
|
||||
llmContent: resultMessage,
|
||||
returnDisplay: `Found ${fileCount} matching file(s)`,
|
||||
returnDisplay: `Found ${totalFileCount} matching file(s)${truncated ? ' (truncated)' : ''}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
@@ -246,7 +194,7 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
const rawError = `Error during glob search operation: ${errorMessage}`;
|
||||
return {
|
||||
llmContent: rawError,
|
||||
returnDisplay: `Error: An unexpected error occurred.`,
|
||||
returnDisplay: `Error: ${errorMessage || 'An unexpected error occurred.'}`,
|
||||
error: {
|
||||
message: rawError,
|
||||
type: ToolErrorType.GLOB_EXECUTION_ERROR,
|
||||
@@ -254,6 +202,18 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getFileFilteringOptions(): FileFilteringOptions {
|
||||
const options = this.config.getFileFilteringOptions?.();
|
||||
return {
|
||||
respectGitIgnore:
|
||||
options?.respectGitIgnore ??
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
|
||||
respectQwenIgnore:
|
||||
options?.respectQwenIgnore ??
|
||||
DEFAULT_FILE_FILTERING_OPTIONS.respectQwenIgnore,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,35 +226,19 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
super(
|
||||
GlobTool.Name,
|
||||
'FindFiles',
|
||||
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
|
||||
'Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n- Returns matching file paths sorted by modification time\n- Use this tool when you need to find files by name patterns\n- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
pattern: {
|
||||
description:
|
||||
"The glob pattern to match against (e.g., '**/*.py', 'docs/*.md').",
|
||||
description: 'The glob pattern to match files against',
|
||||
type: 'string',
|
||||
},
|
||||
path: {
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the root directory.',
|
||||
'The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
|
||||
type: 'string',
|
||||
},
|
||||
case_sensitive: {
|
||||
description:
|
||||
'Optional: Whether the search should be case-sensitive. Defaults to false.',
|
||||
type: 'boolean',
|
||||
},
|
||||
respect_git_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .gitignore patterns when finding files. Only available in git repositories. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
respect_qwen_ignore: {
|
||||
description:
|
||||
'Optional: Whether to respect .qwenignore patterns when finding files. Defaults to true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
type: 'object',
|
||||
@@ -308,29 +252,6 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
protected override validateToolParamValues(
|
||||
params: GlobToolParams,
|
||||
): string | null {
|
||||
const searchDirAbsolute = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
params.path || '.',
|
||||
);
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
return `Search path ("${searchDirAbsolute}") resolves outside the allowed workspace directories: ${directories.join(', ')}`;
|
||||
}
|
||||
|
||||
const targetDir = searchDirAbsolute || this.config.getTargetDir();
|
||||
try {
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
return `Search path does not exist ${targetDir}`;
|
||||
}
|
||||
if (!fs.statSync(targetDir).isDirectory()) {
|
||||
return `Search path is not a directory: ${targetDir}`;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
return `Error accessing search path: ${e}`;
|
||||
}
|
||||
|
||||
if (
|
||||
!params.pattern ||
|
||||
typeof params.pattern !== 'string' ||
|
||||
@@ -339,6 +260,15 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
|
||||
return "The 'pattern' parameter cannot be empty.";
|
||||
}
|
||||
|
||||
// Only validate path if one is provided
|
||||
if (params.path) {
|
||||
try {
|
||||
resolveAndValidatePath(this.config, params.path);
|
||||
} catch (error) {
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,11 +84,11 @@ describe('GrepTool', () => {
|
||||
expect(grepTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid params (pattern, path, and include)', () => {
|
||||
it('should return null for valid params (pattern, path, and glob)', () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'hello',
|
||||
path: '.',
|
||||
include: '*.txt',
|
||||
glob: '*.txt',
|
||||
};
|
||||
expect(grepTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describe('GrepTool', () => {
|
||||
const params: GrepToolParams = { pattern: 'hello', path: 'nonexistent' };
|
||||
// Check for the core error message, as the full path might vary
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
'Failed to access path stats for',
|
||||
'Path does not exist:',
|
||||
);
|
||||
expect(grepTool.validateToolParams(params)).toContain('nonexistent');
|
||||
});
|
||||
@@ -155,8 +155,8 @@ describe('GrepTool', () => {
|
||||
expect(result.returnDisplay).toBe('Found 1 match');
|
||||
});
|
||||
|
||||
it('should find matches with an include glob', async () => {
|
||||
const params: GrepToolParams = { pattern: 'hello', include: '*.js' };
|
||||
it('should find matches with a glob filter', async () => {
|
||||
const params: GrepToolParams = { pattern: 'hello', glob: '*.js' };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain(
|
||||
@@ -169,7 +169,7 @@ describe('GrepTool', () => {
|
||||
expect(result.returnDisplay).toBe('Found 1 match');
|
||||
});
|
||||
|
||||
it('should find matches with an include glob and path', async () => {
|
||||
it('should find matches with a glob filter and path', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'sub', 'another.js'),
|
||||
'const greeting = "hello";',
|
||||
@@ -177,7 +177,7 @@ describe('GrepTool', () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'hello',
|
||||
path: 'sub',
|
||||
include: '*.js',
|
||||
glob: '*.js',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
@@ -244,59 +244,23 @@ describe('GrepTool', () => {
|
||||
|
||||
describe('multi-directory workspace', () => {
|
||||
it('should search across all workspace directories when no path is specified', async () => {
|
||||
// Create additional directory with test files
|
||||
const secondDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'grep-tool-second-'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(secondDir, 'other.txt'),
|
||||
'hello from second directory\nworld in second',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(secondDir, 'another.js'),
|
||||
'function world() { return "test"; }',
|
||||
);
|
||||
|
||||
// Create a mock config with multiple directories
|
||||
const multiDirConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, [secondDir]),
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
// The new implementation searches only in the target directory (first workspace directory)
|
||||
// when no path is specified, not across all workspace directories
|
||||
const params: GrepToolParams = { pattern: 'world' };
|
||||
const invocation = multiDirGrepTool.build(params);
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// Should find matches in both directories
|
||||
// Should find matches in the target directory only
|
||||
expect(result.llmContent).toContain(
|
||||
'Found 5 matches for pattern "world"',
|
||||
'Found 3 matches for pattern "world" in the workspace directory',
|
||||
);
|
||||
|
||||
// Matches from first directory
|
||||
// Matches from target directory
|
||||
expect(result.llmContent).toContain('fileA.txt');
|
||||
expect(result.llmContent).toContain('L1: hello world');
|
||||
expect(result.llmContent).toContain('L2: second line with world');
|
||||
expect(result.llmContent).toContain('fileC.txt');
|
||||
expect(result.llmContent).toContain('L1: another world in sub dir');
|
||||
|
||||
// Matches from second directory (with directory name prefix)
|
||||
const secondDirName = path.basename(secondDir);
|
||||
expect(result.llmContent).toContain(
|
||||
`File: ${path.join(secondDirName, 'other.txt')}`,
|
||||
);
|
||||
expect(result.llmContent).toContain('L2: world in second');
|
||||
expect(result.llmContent).toContain(
|
||||
`File: ${path.join(secondDirName, 'another.js')}`,
|
||||
);
|
||||
expect(result.llmContent).toContain('L1: function world()');
|
||||
|
||||
// Clean up
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should search only specified path within workspace directories', async () => {
|
||||
@@ -346,16 +310,18 @@ describe('GrepTool', () => {
|
||||
it('should generate correct description with pattern only', () => {
|
||||
const params: GrepToolParams = { pattern: 'testPattern' };
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern'");
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in path './'");
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern and include', () => {
|
||||
it('should generate correct description with pattern and glob', () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'testPattern',
|
||||
include: '*.ts',
|
||||
glob: '*.ts',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in *.ts");
|
||||
expect(invocation.getDescription()).toBe(
|
||||
"'testPattern' in path './' (filter: '*.ts')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern and path', async () => {
|
||||
@@ -366,49 +332,37 @@ describe('GrepTool', () => {
|
||||
path: path.join('src', 'app'),
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
// The path will be relative to the tempRootDir, so we check for containment.
|
||||
expect(invocation.getDescription()).toContain("'testPattern' within");
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
});
|
||||
|
||||
it('should indicate searching across all workspace directories when no path specified', () => {
|
||||
// Create a mock config with multiple directories
|
||||
const multiDirConfig = {
|
||||
getTargetDir: () => tempRootDir,
|
||||
getWorkspaceContext: () =>
|
||||
createMockWorkspaceContext(tempRootDir, ['/another/dir']),
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
const params: GrepToolParams = { pattern: 'testPattern' };
|
||||
const invocation = multiDirGrepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe(
|
||||
"'testPattern' across all workspace directories",
|
||||
expect(invocation.getDescription()).toContain(
|
||||
"'testPattern' in path 'src",
|
||||
);
|
||||
expect(invocation.getDescription()).toContain("app'");
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern, include, and path', async () => {
|
||||
it('should indicate searching workspace directory when no path specified', () => {
|
||||
const params: GrepToolParams = { pattern: 'testPattern' };
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in path './'");
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern, glob, and path', async () => {
|
||||
const dirPath = path.join(tempRootDir, 'src', 'app');
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'testPattern',
|
||||
include: '*.ts',
|
||||
glob: '*.ts',
|
||||
path: path.join('src', 'app'),
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toContain(
|
||||
"'testPattern' in *.ts within",
|
||||
"'testPattern' in path 'src",
|
||||
);
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
expect(invocation.getDescription()).toContain("(filter: '*.ts')");
|
||||
});
|
||||
|
||||
it('should use ./ for root path in description', () => {
|
||||
const params: GrepToolParams = { pattern: 'testPattern', path: '.' };
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' within ./");
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in path '.'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -422,67 +376,50 @@ describe('GrepTool', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should limit results to default 20 matches', async () => {
|
||||
it('should show all results when no limit is specified', async () => {
|
||||
const params: GrepToolParams = { pattern: 'testword' };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 20 matches');
|
||||
expect(result.llmContent).toContain(
|
||||
'showing first 20 of 30+ total matches',
|
||||
);
|
||||
expect(result.llmContent).toContain('WARNING: Results truncated');
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Found 20 matches (truncated from 30+)',
|
||||
);
|
||||
// New implementation shows all matches when limit is not specified
|
||||
expect(result.llmContent).toContain('Found 30 matches');
|
||||
expect(result.llmContent).not.toContain('truncated');
|
||||
expect(result.returnDisplay).toBe('Found 30 matches');
|
||||
});
|
||||
|
||||
it('should respect custom maxResults parameter', async () => {
|
||||
const params: GrepToolParams = { pattern: 'testword', maxResults: 5 };
|
||||
it('should respect custom limit parameter', async () => {
|
||||
const params: GrepToolParams = { pattern: 'testword', limit: 5 };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 5 matches');
|
||||
expect(result.llmContent).toContain(
|
||||
'showing first 5 of 30+ total matches',
|
||||
);
|
||||
expect(result.llmContent).toContain('current: 5');
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Found 5 matches (truncated from 30+)',
|
||||
);
|
||||
// Should find 30 total but limit to 5
|
||||
expect(result.llmContent).toContain('Found 30 matches');
|
||||
expect(result.llmContent).toContain('25 lines truncated');
|
||||
expect(result.returnDisplay).toContain('Found 30 matches (truncated)');
|
||||
});
|
||||
|
||||
it('should not show truncation warning when all results fit', async () => {
|
||||
const params: GrepToolParams = { pattern: 'testword', maxResults: 50 };
|
||||
const params: GrepToolParams = { pattern: 'testword', limit: 50 };
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 30 matches');
|
||||
expect(result.llmContent).not.toContain('WARNING: Results truncated');
|
||||
expect(result.llmContent).not.toContain('showing first');
|
||||
expect(result.llmContent).not.toContain('truncated');
|
||||
expect(result.returnDisplay).toBe('Found 30 matches');
|
||||
});
|
||||
|
||||
it('should validate maxResults parameter', () => {
|
||||
const invalidParams = [
|
||||
{ pattern: 'test', maxResults: 0 },
|
||||
{ pattern: 'test', maxResults: 101 },
|
||||
{ pattern: 'test', maxResults: -1 },
|
||||
{ pattern: 'test', maxResults: 1.5 },
|
||||
];
|
||||
|
||||
invalidParams.forEach((params) => {
|
||||
const error = grepTool.validateToolParams(params as GrepToolParams);
|
||||
expect(error).toBeTruthy(); // Just check that validation fails
|
||||
expect(error).toMatch(/maxResults|must be/); // Check it's about maxResults validation
|
||||
});
|
||||
it('should not validate limit parameter', () => {
|
||||
// limit parameter has no validation constraints in the new implementation
|
||||
const params = { pattern: 'test', limit: 5 };
|
||||
const error = grepTool.validateToolParams(params as GrepToolParams);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should accept valid maxResults parameter', () => {
|
||||
it('should accept valid limit parameter', () => {
|
||||
const validParams = [
|
||||
{ pattern: 'test', maxResults: 1 },
|
||||
{ pattern: 'test', maxResults: 50 },
|
||||
{ pattern: 'test', maxResults: 100 },
|
||||
{ pattern: 'test', limit: 1 },
|
||||
{ pattern: 'test', limit: 50 },
|
||||
{ pattern: 'test', limit: 100 },
|
||||
];
|
||||
|
||||
validParams.forEach((params) => {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { EOL } from 'node:os';
|
||||
@@ -13,13 +12,15 @@ import { globStream } from 'glob';
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames } from './tool-names.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { resolveAndValidatePath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { isGitRepository } from '../utils/gitUtils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { FileExclusions } from '../utils/ignorePatterns.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
const MAX_LLM_CONTENT_LENGTH = 20_000;
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
/**
|
||||
@@ -37,14 +38,14 @@ export interface GrepToolParams {
|
||||
path?: string;
|
||||
|
||||
/**
|
||||
* File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")
|
||||
* Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")
|
||||
*/
|
||||
include?: string;
|
||||
glob?: string;
|
||||
|
||||
/**
|
||||
* Maximum number of matches to return (optional, defaults to 20)
|
||||
* Maximum number of matching lines to return (optional, shows all if not specified)
|
||||
*/
|
||||
maxResults?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,121 +71,57 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
this.fileExclusions = config.getFileExclusions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory and resolves it.
|
||||
* @param relativePath Path relative to the root directory (or undefined for root).
|
||||
* @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
|
||||
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
|
||||
*/
|
||||
private resolveAndValidatePath(relativePath?: string): string | null {
|
||||
// If no path specified, return null to indicate searching all workspace directories
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
|
||||
|
||||
// Security Check: Ensure the resolved path is within workspace boundaries
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check existence and type after resolving
|
||||
try {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${targetPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
try {
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const searchDirAbs = this.resolveAndValidatePath(this.params.path);
|
||||
// Default to target directory if no path is provided
|
||||
const searchDirAbs = resolveAndValidatePath(
|
||||
this.config,
|
||||
this.params.path,
|
||||
);
|
||||
const searchDirDisplay = this.params.path || '.';
|
||||
|
||||
// Determine which directories to search
|
||||
let searchDirectories: readonly string[];
|
||||
if (searchDirAbs === null) {
|
||||
// No path specified - search all workspace directories
|
||||
searchDirectories = workspaceContext.getDirectories();
|
||||
} else {
|
||||
// Specific path provided - search only that directory
|
||||
searchDirectories = [searchDirAbs];
|
||||
}
|
||||
// Perform grep search
|
||||
const rawMatches = await this.performGrepSearch({
|
||||
pattern: this.params.pattern,
|
||||
path: searchDirAbs,
|
||||
glob: this.params.glob,
|
||||
signal,
|
||||
});
|
||||
|
||||
// Collect matches from all search directories
|
||||
let allMatches: GrepMatch[] = [];
|
||||
const maxResults = this.params.maxResults ?? 20; // Default to 20 results
|
||||
let totalMatchesFound = 0;
|
||||
let searchTruncated = false;
|
||||
// Build search description
|
||||
const searchLocationDescription = this.params.path
|
||||
? `in path "${searchDirDisplay}"`
|
||||
: `in the workspace directory`;
|
||||
|
||||
for (const searchDir of searchDirectories) {
|
||||
const matches = await this.performGrepSearch({
|
||||
pattern: this.params.pattern,
|
||||
path: searchDir,
|
||||
include: this.params.include,
|
||||
signal,
|
||||
});
|
||||
const filterDescription = this.params.glob
|
||||
? ` (filter: "${this.params.glob}")`
|
||||
: '';
|
||||
|
||||
totalMatchesFound += matches.length;
|
||||
|
||||
// Add directory prefix if searching multiple directories
|
||||
if (searchDirectories.length > 1) {
|
||||
const dirName = path.basename(searchDir);
|
||||
matches.forEach((match) => {
|
||||
match.filePath = path.join(dirName, match.filePath);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply result limiting
|
||||
const remainingSlots = maxResults - allMatches.length;
|
||||
if (remainingSlots <= 0) {
|
||||
searchTruncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (matches.length > remainingSlots) {
|
||||
allMatches = allMatches.concat(matches.slice(0, remainingSlots));
|
||||
searchTruncated = true;
|
||||
break;
|
||||
} else {
|
||||
allMatches = allMatches.concat(matches);
|
||||
}
|
||||
}
|
||||
|
||||
let searchLocationDescription: string;
|
||||
if (searchDirAbs === null) {
|
||||
const numDirs = workspaceContext.getDirectories().length;
|
||||
searchLocationDescription =
|
||||
numDirs > 1
|
||||
? `across ${numDirs} workspace directories`
|
||||
: `in the workspace directory`;
|
||||
} else {
|
||||
searchLocationDescription = `in path "${searchDirDisplay}"`;
|
||||
}
|
||||
|
||||
if (allMatches.length === 0) {
|
||||
const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`;
|
||||
// Check if we have any matches
|
||||
if (rawMatches.length === 0) {
|
||||
const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}.`;
|
||||
return { llmContent: noMatchMsg, returnDisplay: `No matches found` };
|
||||
}
|
||||
|
||||
// Apply line limit if specified
|
||||
let truncatedByLineLimit = false;
|
||||
let matchesToInclude = rawMatches;
|
||||
if (
|
||||
this.params.limit !== undefined &&
|
||||
rawMatches.length > this.params.limit
|
||||
) {
|
||||
matchesToInclude = rawMatches.slice(0, this.params.limit);
|
||||
truncatedByLineLimit = true;
|
||||
}
|
||||
|
||||
const totalMatches = rawMatches.length;
|
||||
const matchTerm = totalMatches === 1 ? 'match' : 'matches';
|
||||
|
||||
// Build header
|
||||
const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`;
|
||||
|
||||
// Group matches by file
|
||||
const matchesByFile = allMatches.reduce(
|
||||
const matchesByFile = matchesToInclude.reduce(
|
||||
(acc, match) => {
|
||||
const fileKey = match.filePath;
|
||||
if (!acc[fileKey]) {
|
||||
@@ -197,46 +134,51 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
{} as Record<string, GrepMatch[]>,
|
||||
);
|
||||
|
||||
const matchCount = allMatches.length;
|
||||
const matchTerm = matchCount === 1 ? 'match' : 'matches';
|
||||
|
||||
// Build the header with truncation info if needed
|
||||
let headerText = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`;
|
||||
|
||||
if (searchTruncated) {
|
||||
headerText += ` (showing first ${matchCount} of ${totalMatchesFound}+ total matches)`;
|
||||
}
|
||||
|
||||
let llmContent = `${headerText}:
|
||||
---
|
||||
`;
|
||||
|
||||
// Build grep output
|
||||
let grepOutput = '';
|
||||
for (const filePath in matchesByFile) {
|
||||
llmContent += `File: ${filePath}\n`;
|
||||
grepOutput += `File: ${filePath}\n`;
|
||||
matchesByFile[filePath].forEach((match) => {
|
||||
const trimmedLine = match.line.trim();
|
||||
llmContent += `L${match.lineNumber}: ${trimmedLine}\n`;
|
||||
grepOutput += `L${match.lineNumber}: ${trimmedLine}\n`;
|
||||
});
|
||||
llmContent += '---\n';
|
||||
grepOutput += '---\n';
|
||||
}
|
||||
|
||||
// Add truncation guidance if results were limited
|
||||
if (searchTruncated) {
|
||||
llmContent += `\nWARNING: Results truncated to prevent context overflow. To see more results:
|
||||
- Use a more specific pattern to reduce matches
|
||||
- Add file filters with the 'include' parameter (e.g., "*.js", "src/**")
|
||||
- Specify a narrower 'path' to search in a subdirectory
|
||||
- Increase 'maxResults' parameter if you need more matches (current: ${maxResults})`;
|
||||
// Apply character limit as safety net
|
||||
let truncatedByCharLimit = false;
|
||||
if (grepOutput.length > MAX_LLM_CONTENT_LENGTH) {
|
||||
grepOutput = grepOutput.slice(0, MAX_LLM_CONTENT_LENGTH) + '...';
|
||||
truncatedByCharLimit = true;
|
||||
}
|
||||
|
||||
let displayText = `Found ${matchCount} ${matchTerm}`;
|
||||
if (searchTruncated) {
|
||||
displayText += ` (truncated from ${totalMatchesFound}+)`;
|
||||
// Count how many lines we actually included after character truncation
|
||||
const finalLines = grepOutput
|
||||
.split('\n')
|
||||
.filter(
|
||||
(line) =>
|
||||
line.trim() && !line.startsWith('File:') && !line.startsWith('---'),
|
||||
);
|
||||
const includedLines = finalLines.length;
|
||||
|
||||
// Build result
|
||||
let llmContent = header + grepOutput;
|
||||
|
||||
// Add truncation notice if needed
|
||||
if (truncatedByLineLimit || truncatedByCharLimit) {
|
||||
const omittedMatches = totalMatches - includedLines;
|
||||
llmContent += ` [${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
|
||||
}
|
||||
|
||||
// Build display message
|
||||
let displayMessage = `Found ${totalMatches} ${matchTerm}`;
|
||||
if (truncatedByLineLimit || truncatedByCharLimit) {
|
||||
displayMessage += ` (truncated)`;
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: displayText,
|
||||
returnDisplay: displayMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error during GrepLogic execution: ${error}`);
|
||||
@@ -329,50 +271,26 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
* @returns A string describing the grep
|
||||
*/
|
||||
getDescription(): string {
|
||||
let description = `'${this.params.pattern}'`;
|
||||
if (this.params.include) {
|
||||
description += ` in ${this.params.include}`;
|
||||
}
|
||||
if (this.params.path) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.path,
|
||||
);
|
||||
if (
|
||||
resolvedPath === this.config.getTargetDir() ||
|
||||
this.params.path === '.'
|
||||
) {
|
||||
description += ` within ./`;
|
||||
} else {
|
||||
const relativePath = makeRelative(
|
||||
resolvedPath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
description += ` within ${shortenPath(relativePath)}`;
|
||||
}
|
||||
} else {
|
||||
// When no path is specified, indicate searching all workspace directories
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
if (directories.length > 1) {
|
||||
description += ` across all workspace directories`;
|
||||
}
|
||||
let description = `'${this.params.pattern}' in path '${this.params.path || './'}'`;
|
||||
if (this.params.glob) {
|
||||
description += ` (filter: '${this.params.glob}')`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual search using the prioritized strategies.
|
||||
* @param options Search options including pattern, absolute path, and include glob.
|
||||
* @param options Search options including pattern, absolute path, and glob filter.
|
||||
* @returns A promise resolving to an array of match objects.
|
||||
*/
|
||||
private async performGrepSearch(options: {
|
||||
pattern: string;
|
||||
path: string; // Expects absolute path
|
||||
include?: string;
|
||||
glob?: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<GrepMatch[]> {
|
||||
const { pattern, path: absolutePath, include } = options;
|
||||
const { pattern, path: absolutePath, glob } = options;
|
||||
let strategyUsed = 'none';
|
||||
|
||||
try {
|
||||
@@ -390,8 +308,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
'--ignore-case',
|
||||
pattern,
|
||||
];
|
||||
if (include) {
|
||||
gitArgs.push('--', include);
|
||||
if (glob) {
|
||||
gitArgs.push('--', glob);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -457,8 +375,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
})
|
||||
.filter((dir): dir is string => !!dir);
|
||||
commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
|
||||
if (include) {
|
||||
grepArgs.push(`--include=${include}`);
|
||||
if (glob) {
|
||||
grepArgs.push(`--include=${glob}`);
|
||||
}
|
||||
grepArgs.push(pattern);
|
||||
grepArgs.push('.');
|
||||
@@ -537,7 +455,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
'GrepLogic: Falling back to JavaScript grep implementation.',
|
||||
);
|
||||
strategyUsed = 'javascript fallback';
|
||||
const globPattern = include ? include : '**/*';
|
||||
const globPattern = glob ? glob : '**/*';
|
||||
const ignorePatterns = this.fileExclusions.getGlobExcludes();
|
||||
|
||||
const filesIterator = globStream(globPattern, {
|
||||
@@ -603,32 +521,30 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
GrepTool.Name,
|
||||
'SearchText',
|
||||
'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.',
|
||||
'Grep',
|
||||
'A powerful search tool for finding patterns in files\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")\n - Filter files with glob parameter (e.g., "*.js", "**/*.tsx")\n - Case-insensitive by default\n - Use Task tool for open-ended searches requiring multiple rounds\n',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
pattern: {
|
||||
description:
|
||||
"The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
|
||||
type: 'string',
|
||||
description:
|
||||
'The regular expression pattern to search for in file contents',
|
||||
},
|
||||
glob: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")',
|
||||
},
|
||||
path: {
|
||||
description:
|
||||
'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.',
|
||||
type: 'string',
|
||||
},
|
||||
include: {
|
||||
description:
|
||||
"Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
|
||||
type: 'string',
|
||||
'File or directory to search in. Defaults to current working directory.',
|
||||
},
|
||||
maxResults: {
|
||||
description:
|
||||
'Optional: Maximum number of matches to return to prevent context overflow (default: 20, max: 100). Use lower values for broad searches, higher for specific searches.',
|
||||
limit: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description:
|
||||
'Limit output to first N matching lines. Optional - shows all matches if not specified.',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
@@ -637,47 +553,6 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory and resolves it.
|
||||
* @param relativePath Path relative to the root directory (or undefined for root).
|
||||
* @returns The absolute path if valid and exists, or null if no path specified (to search all directories).
|
||||
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
|
||||
*/
|
||||
private resolveAndValidatePath(relativePath?: string): string | null {
|
||||
// If no path specified, return null to indicate searching all workspace directories
|
||||
if (!relativePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
|
||||
|
||||
// Security Check: Ensure the resolved path is within workspace boundaries
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check existence and type after resolving
|
||||
try {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${targetPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the tool
|
||||
* @param params Parameters to validate
|
||||
@@ -686,27 +561,17 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
protected override validateToolParamValues(
|
||||
params: GrepToolParams,
|
||||
): string | null {
|
||||
// Validate pattern is a valid regex
|
||||
try {
|
||||
new RegExp(params.pattern);
|
||||
} catch (error) {
|
||||
return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
|
||||
// Validate maxResults if provided
|
||||
if (params.maxResults !== undefined) {
|
||||
if (
|
||||
!Number.isInteger(params.maxResults) ||
|
||||
params.maxResults < 1 ||
|
||||
params.maxResults > 100
|
||||
) {
|
||||
return `maxResults must be an integer between 1 and 100, got: ${params.maxResults}`;
|
||||
}
|
||||
return `Invalid regular expression pattern: ${params.pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
|
||||
// Only validate path if one is provided
|
||||
if (params.path) {
|
||||
try {
|
||||
this.resolveAndValidatePath(params.path);
|
||||
resolveAndValidatePath(this.config, params.path);
|
||||
} catch (error) {
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
|
||||
@@ -184,17 +184,15 @@ describe('RipGrepTool', () => {
|
||||
};
|
||||
// Check for the core error message, as the full path might vary
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
'Failed to access path stats for',
|
||||
'Path does not exist:',
|
||||
);
|
||||
expect(grepTool.validateToolParams(params)).toContain('nonexistent');
|
||||
});
|
||||
|
||||
it('should return error if path is a file, not a directory', async () => {
|
||||
it('should allow path to be a file', () => {
|
||||
const filePath = path.join(tempRootDir, 'fileA.txt');
|
||||
const params: RipGrepToolParams = { pattern: 'hello', path: filePath };
|
||||
expect(grepTool.validateToolParams(params)).toContain(
|
||||
`Path is not a directory: ${filePath}`,
|
||||
);
|
||||
expect(grepTool.validateToolParams(params)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -432,7 +430,7 @@ describe('RipGrepTool', () => {
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(String(result.llmContent).length).toBeLessThanOrEqual(20_000);
|
||||
expect(String(result.llmContent).length).toBeLessThanOrEqual(21_000);
|
||||
expect(result.llmContent).toMatch(/\[\d+ lines? truncated\] \.\.\./);
|
||||
expect(result.returnDisplay).toContain('truncated');
|
||||
});
|
||||
@@ -567,6 +565,26 @@ describe('RipGrepTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should search within a single file when path is a file', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`,
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'world',
|
||||
path: path.join(tempRootDir, 'fileA.txt'),
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain('fileA.txt:1:hello world');
|
||||
expect(result.llmContent).toContain('fileA.txt:2:second line with world');
|
||||
expect(result.returnDisplay).toBe('Found 2 matches');
|
||||
});
|
||||
|
||||
it('should throw an error if ripgrep is not available', async () => {
|
||||
// Make ensureRipgrepBinary throw
|
||||
(ensureRipgrepPath as Mock).mockRejectedValue(
|
||||
@@ -648,7 +666,9 @@ describe('RipGrepTool', () => {
|
||||
describe('error handling and edge cases', () => {
|
||||
it('should handle workspace boundary violations', () => {
|
||||
const params: RipGrepToolParams = { pattern: 'test', path: '../outside' };
|
||||
expect(() => grepTool.build(params)).toThrow(/Path validation failed/);
|
||||
expect(() => grepTool.build(params)).toThrow(
|
||||
/Path is not within workspace/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty directories gracefully', async () => {
|
||||
@@ -1132,7 +1152,9 @@ describe('RipGrepTool', () => {
|
||||
glob: '*.ts',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in *.ts");
|
||||
expect(invocation.getDescription()).toBe(
|
||||
"'testPattern' (filter: '*.ts')",
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate correct description with pattern and path', async () => {
|
||||
@@ -1143,9 +1165,10 @@ describe('RipGrepTool', () => {
|
||||
path: path.join('src', 'app'),
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
// The path will be relative to the tempRootDir, so we check for containment.
|
||||
expect(invocation.getDescription()).toContain("'testPattern' within");
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
expect(invocation.getDescription()).toContain(
|
||||
"'testPattern' in path 'src",
|
||||
);
|
||||
expect(invocation.getDescription()).toContain("app'");
|
||||
});
|
||||
|
||||
it('should generate correct description with default search path', () => {
|
||||
@@ -1164,15 +1187,15 @@ describe('RipGrepTool', () => {
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toContain(
|
||||
"'testPattern' in *.ts within",
|
||||
"'testPattern' in path 'src",
|
||||
);
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
expect(invocation.getDescription()).toContain("(filter: '*.ts')");
|
||||
});
|
||||
|
||||
it('should use ./ for root path in description', () => {
|
||||
it('should use path when specified in description', () => {
|
||||
const params: RipGrepToolParams = { pattern: 'testPattern', path: '.' };
|
||||
const invocation = grepTool.build(params);
|
||||
expect(invocation.getDescription()).toBe("'testPattern' within ./");
|
||||
expect(invocation.getDescription()).toBe("'testPattern' in path '.'");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@ import { spawn } from 'node:child_process';
|
||||
import type { ToolInvocation, ToolResult } from './tools.js';
|
||||
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
|
||||
import { ToolNames } from './tool-names.js';
|
||||
import { makeRelative, shortenPath } from '../utils/paths.js';
|
||||
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||
import { resolveAndValidatePath } from '../utils/paths.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { ensureRipgrepPath } from '../utils/ripgrepUtils.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
@@ -57,50 +57,13 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
super(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory and resolves it.
|
||||
* @param relativePath Path relative to the root directory (or undefined for root).
|
||||
* @returns The absolute path to search within.
|
||||
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
|
||||
*/
|
||||
private resolveAndValidatePath(relativePath?: string): string {
|
||||
const targetDir = this.config.getTargetDir();
|
||||
const targetPath = relativePath
|
||||
? path.resolve(targetDir, relativePath)
|
||||
: targetDir;
|
||||
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.ensureDirectory(targetPath);
|
||||
}
|
||||
|
||||
private ensureDirectory(targetPath: string): string {
|
||||
try {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${targetPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
async execute(signal: AbortSignal): Promise<ToolResult> {
|
||||
try {
|
||||
const searchDirAbs = this.resolveAndValidatePath(this.params.path);
|
||||
const searchDirAbs = resolveAndValidatePath(
|
||||
this.config,
|
||||
this.params.path,
|
||||
{ allowFiles: true },
|
||||
);
|
||||
const searchDirDisplay = this.params.path || '.';
|
||||
|
||||
// Get raw ripgrep output
|
||||
@@ -133,9 +96,6 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
// Build header early to calculate available space
|
||||
const header = `Found ${totalMatches} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${filterDescription}:\n---\n`;
|
||||
const maxTruncationNoticeLength = 100; // "[... N more matches truncated]"
|
||||
const maxGrepOutputLength =
|
||||
MAX_LLM_CONTENT_LENGTH - header.length - maxTruncationNoticeLength;
|
||||
|
||||
// Apply line limit first (if specified)
|
||||
let truncatedByLineLimit = false;
|
||||
@@ -148,19 +108,32 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
truncatedByLineLimit = true;
|
||||
}
|
||||
|
||||
// Join lines back into grep output
|
||||
let grepOutput = linesToInclude.join(EOL);
|
||||
|
||||
// Apply character limit as safety net
|
||||
// Build output and track how many lines we include, respecting character limit
|
||||
const parts: string[] = [];
|
||||
let includedLines = 0;
|
||||
let truncatedByCharLimit = false;
|
||||
if (grepOutput.length > maxGrepOutputLength) {
|
||||
grepOutput = grepOutput.slice(0, maxGrepOutputLength) + '...';
|
||||
truncatedByCharLimit = true;
|
||||
let currentLength = 0;
|
||||
|
||||
for (const line of linesToInclude) {
|
||||
const sep = includedLines > 0 ? 1 : 0;
|
||||
|
||||
includedLines++;
|
||||
|
||||
if (currentLength + line.length <= MAX_LLM_CONTENT_LENGTH) {
|
||||
parts.push(line);
|
||||
currentLength = currentLength + line.length + sep;
|
||||
} else {
|
||||
const remaining = Math.max(
|
||||
MAX_LLM_CONTENT_LENGTH - currentLength - sep,
|
||||
10,
|
||||
);
|
||||
parts.push(line.slice(0, remaining) + '...');
|
||||
truncatedByCharLimit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Count how many lines we actually included after character truncation
|
||||
const finalLines = grepOutput.split(EOL).filter((line) => line.trim());
|
||||
const includedLines = finalLines.length;
|
||||
const grepOutput = parts.join('\n');
|
||||
|
||||
// Build result
|
||||
let llmContent = header + grepOutput;
|
||||
@@ -168,7 +141,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
// Add truncation notice if needed
|
||||
if (truncatedByLineLimit || truncatedByCharLimit) {
|
||||
const omittedMatches = totalMatches - includedLines;
|
||||
llmContent += ` [${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
|
||||
llmContent += `\n---\n[${omittedMatches} ${omittedMatches === 1 ? 'line' : 'lines'} truncated] ...`;
|
||||
}
|
||||
|
||||
// Build display message (show real count, not truncated)
|
||||
@@ -193,7 +166,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
private async performRipgrepSearch(options: {
|
||||
pattern: string;
|
||||
path: string;
|
||||
path: string; // Can be a file or directory
|
||||
glob?: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<string> {
|
||||
@@ -302,34 +275,13 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
*/
|
||||
getDescription(): string {
|
||||
let description = `'${this.params.pattern}'`;
|
||||
if (this.params.glob) {
|
||||
description += ` in ${this.params.glob}`;
|
||||
}
|
||||
if (this.params.path) {
|
||||
const resolvedPath = path.resolve(
|
||||
this.config.getTargetDir(),
|
||||
this.params.path,
|
||||
);
|
||||
if (
|
||||
resolvedPath === this.config.getTargetDir() ||
|
||||
this.params.path === '.'
|
||||
) {
|
||||
description += ` within ./`;
|
||||
} else {
|
||||
const relativePath = makeRelative(
|
||||
resolvedPath,
|
||||
this.config.getTargetDir(),
|
||||
);
|
||||
description += ` within ${shortenPath(relativePath)}`;
|
||||
}
|
||||
} else {
|
||||
// When no path is specified, indicate searching all workspace directories
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
if (directories.length > 1) {
|
||||
description += ` across all workspace directories`;
|
||||
}
|
||||
description += ` in path '${this.params.path}'`;
|
||||
}
|
||||
if (this.params.glob) {
|
||||
description += ` (filter: '${this.params.glob}')`;
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
}
|
||||
@@ -378,47 +330,6 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is within the root directory and resolves it.
|
||||
* @param relativePath Path relative to the root directory (or undefined for root).
|
||||
* @returns The absolute path to search within.
|
||||
* @throws {Error} If path is outside root, doesn't exist, or isn't a directory.
|
||||
*/
|
||||
private resolveAndValidatePath(relativePath?: string): string {
|
||||
// If no path specified, search within the workspace root directory
|
||||
if (!relativePath) {
|
||||
return this.config.getTargetDir();
|
||||
}
|
||||
|
||||
const targetPath = path.resolve(this.config.getTargetDir(), relativePath);
|
||||
|
||||
// Security Check: Ensure the resolved path is within workspace boundaries
|
||||
const workspaceContext = this.config.getWorkspaceContext();
|
||||
if (!workspaceContext.isPathWithinWorkspace(targetPath)) {
|
||||
const directories = workspaceContext.getDirectories();
|
||||
throw new Error(
|
||||
`Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check existence and type after resolving
|
||||
try {
|
||||
const stats = fs.statSync(targetPath);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${targetPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${targetPath}`);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to access path stats for ${targetPath}: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the parameters for the tool
|
||||
* @param params Parameters to validate
|
||||
@@ -445,7 +356,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
// Only validate path if one is provided
|
||||
if (params.path) {
|
||||
try {
|
||||
this.resolveAndValidatePath(params.path);
|
||||
resolveAndValidatePath(this.config, params.path, { allowFiles: true });
|
||||
} catch (error) {
|
||||
return getErrorMessage(error);
|
||||
}
|
||||
|
||||
381
packages/core/src/utils/openaiLogger.test.ts
Normal file
381
packages/core/src/utils/openaiLogger.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'os';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { OpenAILogger } from './openaiLogger.js';
|
||||
|
||||
describe('OpenAILogger', () => {
|
||||
let originalCwd: string;
|
||||
let testTempDir: string;
|
||||
const createdDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
originalCwd = process.cwd();
|
||||
testTempDir = path.join(os.tmpdir(), `openai-logger-test-${Date.now()}`);
|
||||
createdDirs.length = 0; // Clear array
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up all created directories
|
||||
const cleanupPromises = [
|
||||
testTempDir,
|
||||
...createdDirs,
|
||||
path.resolve(process.cwd(), 'relative-logs'),
|
||||
path.resolve(process.cwd(), 'custom-logs'),
|
||||
path.resolve(process.cwd(), 'test-relative-logs'),
|
||||
path.join(os.homedir(), 'custom-logs'),
|
||||
path.join(os.homedir(), 'test-openai-logs'),
|
||||
].map(async (dir) => {
|
||||
try {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(cleanupPromises);
|
||||
process.chdir(originalCwd);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should use default directory when no custom directory is provided', () => {
|
||||
const logger = new OpenAILogger();
|
||||
// We can't directly access private logDir, but we can verify behavior
|
||||
expect(logger).toBeInstanceOf(OpenAILogger);
|
||||
});
|
||||
|
||||
it('should accept absolute path as custom directory', () => {
|
||||
const customDir = '/absolute/path/to/logs';
|
||||
const logger = new OpenAILogger(customDir);
|
||||
expect(logger).toBeInstanceOf(OpenAILogger);
|
||||
});
|
||||
|
||||
it('should resolve relative path to absolute path', async () => {
|
||||
const relativeDir = 'custom-logs';
|
||||
const logger = new OpenAILogger(relativeDir);
|
||||
const expectedDir = path.resolve(process.cwd(), relativeDir);
|
||||
createdDirs.push(expectedDir);
|
||||
expect(logger).toBeInstanceOf(OpenAILogger);
|
||||
});
|
||||
|
||||
it('should expand ~ to home directory', () => {
|
||||
const customDir = '~/custom-logs';
|
||||
const logger = new OpenAILogger(customDir);
|
||||
expect(logger).toBeInstanceOf(OpenAILogger);
|
||||
});
|
||||
|
||||
it('should expand ~/ to home directory', () => {
|
||||
const customDir = '~/custom-logs';
|
||||
const logger = new OpenAILogger(customDir);
|
||||
expect(logger).toBeInstanceOf(OpenAILogger);
|
||||
});
|
||||
|
||||
it('should handle just ~ as home directory', () => {
|
||||
const customDir = '~';
|
||||
const logger = new OpenAILogger(customDir);
|
||||
expect(logger).toBeInstanceOf(OpenAILogger);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should create directory if it does not exist', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
const dirExists = await fs
|
||||
.access(testTempDir)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(dirExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should create nested directories recursively', async () => {
|
||||
const nestedDir = path.join(testTempDir, 'nested', 'deep', 'path');
|
||||
const logger = new OpenAILogger(nestedDir);
|
||||
await logger.initialize();
|
||||
|
||||
const dirExists = await fs
|
||||
.access(nestedDir)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(dirExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should not throw if directory already exists', async () => {
|
||||
await fs.mkdir(testTempDir, { recursive: true });
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await expect(logger.initialize()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logInteraction', () => {
|
||||
it('should create log file with correct format', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
const request = {
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
};
|
||||
const response = { id: 'test-id', choices: [] };
|
||||
|
||||
const logPath = await logger.logInteraction(request, response);
|
||||
|
||||
expect(logPath).toContain(testTempDir);
|
||||
expect(logPath).toMatch(
|
||||
/openai-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z-[a-f0-9]{8}\.json/,
|
||||
);
|
||||
|
||||
const fileExists = await fs
|
||||
.access(logPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(fileExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should write correct log data structure', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
const request = {
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
};
|
||||
const response = { id: 'test-id', choices: [] };
|
||||
|
||||
const logPath = await logger.logInteraction(request, response);
|
||||
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8'));
|
||||
|
||||
expect(logContent).toHaveProperty('timestamp');
|
||||
expect(logContent).toHaveProperty('request', request);
|
||||
expect(logContent).toHaveProperty('response', response);
|
||||
expect(logContent).toHaveProperty('error', null);
|
||||
expect(logContent).toHaveProperty('system');
|
||||
expect(logContent.system).toHaveProperty('hostname');
|
||||
expect(logContent.system).toHaveProperty('platform');
|
||||
expect(logContent.system).toHaveProperty('release');
|
||||
expect(logContent.system).toHaveProperty('nodeVersion');
|
||||
});
|
||||
|
||||
it('should log error when provided', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
const request = {
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
};
|
||||
const error = new Error('Test error');
|
||||
|
||||
const logPath = await logger.logInteraction(request, undefined, error);
|
||||
const logContent = JSON.parse(await fs.readFile(logPath, 'utf-8'));
|
||||
|
||||
expect(logContent).toHaveProperty('error');
|
||||
expect(logContent.error).toHaveProperty('message', 'Test error');
|
||||
expect(logContent.error).toHaveProperty('stack');
|
||||
expect(logContent.response).toBeNull();
|
||||
});
|
||||
|
||||
it('should use custom directory when provided', async () => {
|
||||
const customDir = path.join(testTempDir, 'custom-logs');
|
||||
const logger = new OpenAILogger(customDir);
|
||||
await logger.initialize();
|
||||
|
||||
const request = {
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
};
|
||||
const response = { id: 'test-id', choices: [] };
|
||||
|
||||
const logPath = await logger.logInteraction(request, response);
|
||||
|
||||
expect(logPath).toContain(customDir);
|
||||
expect(logPath.startsWith(customDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve relative path correctly', async () => {
|
||||
const relativeDir = 'relative-logs';
|
||||
const logger = new OpenAILogger(relativeDir);
|
||||
await logger.initialize();
|
||||
|
||||
const request = {
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
};
|
||||
const response = { id: 'test-id', choices: [] };
|
||||
|
||||
const logPath = await logger.logInteraction(request, response);
|
||||
const expectedDir = path.resolve(process.cwd(), relativeDir);
|
||||
createdDirs.push(expectedDir);
|
||||
|
||||
expect(logPath).toContain(expectedDir);
|
||||
});
|
||||
|
||||
it('should expand ~ correctly', async () => {
|
||||
const customDir = '~/test-openai-logs';
|
||||
const logger = new OpenAILogger(customDir);
|
||||
await logger.initialize();
|
||||
|
||||
const request = {
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
};
|
||||
const response = { id: 'test-id', choices: [] };
|
||||
|
||||
const logPath = await logger.logInteraction(request, response);
|
||||
const expectedDir = path.join(os.homedir(), 'test-openai-logs');
|
||||
createdDirs.push(expectedDir);
|
||||
|
||||
expect(logPath).toContain(expectedDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogFiles', () => {
|
||||
it('should return empty array when directory does not exist', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
const files = await logger.getLogFiles();
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return log files after initialization', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
const request = {
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
};
|
||||
const response = { id: 'test-id', choices: [] };
|
||||
|
||||
await logger.logInteraction(request, response);
|
||||
const files = await logger.getLogFiles();
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files[0]).toMatch(/openai-.*\.json$/);
|
||||
});
|
||||
|
||||
it('should return only log files matching pattern', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
// Create a log file
|
||||
await logger.logInteraction({ test: 'request' }, { test: 'response' });
|
||||
|
||||
// Create a non-log file
|
||||
await fs.writeFile(path.join(testTempDir, 'other-file.txt'), 'content');
|
||||
|
||||
const files = await logger.getLogFiles();
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]).toMatch(/openai-.*\.json$/);
|
||||
});
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
// Create multiple log files
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await logger.logInteraction(
|
||||
{ test: `request-${i}` },
|
||||
{ test: `response-${i}` },
|
||||
);
|
||||
// Small delay to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const allFiles = await logger.getLogFiles();
|
||||
expect(allFiles.length).toBe(5);
|
||||
|
||||
const limitedFiles = await logger.getLogFiles(3);
|
||||
expect(limitedFiles.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should return files sorted by most recent first', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const logPath = await logger.logInteraction(
|
||||
{ test: `request-${i}` },
|
||||
{ test: `response-${i}` },
|
||||
);
|
||||
files.push(logPath);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const retrievedFiles = await logger.getLogFiles();
|
||||
expect(retrievedFiles[0]).toBe(files[2]); // Most recent first
|
||||
expect(retrievedFiles[1]).toBe(files[1]);
|
||||
expect(retrievedFiles[2]).toBe(files[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLogFile', () => {
|
||||
it('should read and parse log file correctly', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
await logger.initialize();
|
||||
|
||||
const request = {
|
||||
model: 'gpt-4',
|
||||
messages: [{ role: 'user', content: 'test' }],
|
||||
};
|
||||
const response = { id: 'test-id', choices: [] };
|
||||
|
||||
const logPath = await logger.logInteraction(request, response);
|
||||
const logData = await logger.readLogFile(logPath);
|
||||
|
||||
expect(logData).toHaveProperty('timestamp');
|
||||
expect(logData).toHaveProperty('request', request);
|
||||
expect(logData).toHaveProperty('response', response);
|
||||
});
|
||||
|
||||
it('should throw error when file does not exist', async () => {
|
||||
const logger = new OpenAILogger(testTempDir);
|
||||
const nonExistentPath = path.join(testTempDir, 'non-existent.json');
|
||||
|
||||
await expect(logger.readLogFile(nonExistentPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('path resolution', () => {
|
||||
it('should normalize absolute paths', () => {
|
||||
const absolutePath = '/tmp/test/logs';
|
||||
const logger = new OpenAILogger(absolutePath);
|
||||
expect(logger).toBeInstanceOf(OpenAILogger);
|
||||
});
|
||||
|
||||
it('should resolve relative paths based on current working directory', async () => {
|
||||
const relativePath = 'test-relative-logs';
|
||||
const logger = new OpenAILogger(relativePath);
|
||||
await logger.initialize();
|
||||
|
||||
const request = { test: 'request' };
|
||||
const response = { test: 'response' };
|
||||
|
||||
const logPath = await logger.logInteraction(request, response);
|
||||
const expectedBaseDir = path.resolve(process.cwd(), relativePath);
|
||||
createdDirs.push(expectedBaseDir);
|
||||
|
||||
expect(logPath).toContain(expectedBaseDir);
|
||||
});
|
||||
|
||||
it('should handle paths with special characters', async () => {
|
||||
const specialPath = path.join(testTempDir, 'logs-with-special-chars');
|
||||
const logger = new OpenAILogger(specialPath);
|
||||
await logger.initialize();
|
||||
|
||||
const request = { test: 'request' };
|
||||
const response = { test: 'response' };
|
||||
|
||||
const logPath = await logger.logInteraction(request, response);
|
||||
expect(logPath).toContain(specialPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,10 +18,23 @@ export class OpenAILogger {
|
||||
|
||||
/**
|
||||
* Creates a new OpenAI logger
|
||||
* @param customLogDir Optional custom log directory path
|
||||
* @param customLogDir Optional custom log directory path (supports relative paths, absolute paths, and ~ expansion)
|
||||
*/
|
||||
constructor(customLogDir?: string) {
|
||||
this.logDir = customLogDir || path.join(process.cwd(), 'logs', 'openai');
|
||||
if (customLogDir) {
|
||||
// Resolve relative paths to absolute paths
|
||||
// Handle ~ expansion
|
||||
let resolvedPath = customLogDir;
|
||||
if (customLogDir === '~' || customLogDir.startsWith('~/')) {
|
||||
resolvedPath = path.join(os.homedir(), customLogDir.slice(1));
|
||||
} else if (!path.isAbsolute(customLogDir)) {
|
||||
// If it's a relative path, resolve it relative to current working directory
|
||||
resolvedPath = path.resolve(process.cwd(), customLogDir);
|
||||
}
|
||||
this.logDir = path.normalize(resolvedPath);
|
||||
} else {
|
||||
this.logDir = path.join(process.cwd(), 'logs', 'openai');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,53 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { escapePath, unescapePath, isSubpath } from './paths.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import {
|
||||
escapePath,
|
||||
resolvePath,
|
||||
validatePath,
|
||||
resolveAndValidatePath,
|
||||
unescapePath,
|
||||
isSubpath,
|
||||
} from './paths.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
function createConfigStub({
|
||||
targetDir,
|
||||
allowedDirectories,
|
||||
}: {
|
||||
targetDir: string;
|
||||
allowedDirectories: string[];
|
||||
}): Config {
|
||||
const resolvedTargetDir = path.resolve(targetDir);
|
||||
const resolvedDirectories = allowedDirectories.map((dir) =>
|
||||
path.resolve(dir),
|
||||
);
|
||||
|
||||
const workspaceContext = {
|
||||
isPathWithinWorkspace(testPath: string) {
|
||||
const resolvedPath = path.resolve(testPath);
|
||||
return resolvedDirectories.some((dir) => {
|
||||
const relative = path.relative(dir, resolvedPath);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
});
|
||||
},
|
||||
getDirectories() {
|
||||
return resolvedDirectories;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getTargetDir: () => resolvedTargetDir,
|
||||
getWorkspaceContext: () => workspaceContext,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('escapePath', () => {
|
||||
it('should escape spaces', () => {
|
||||
@@ -314,3 +359,240 @@ describe('isSubpath on Windows', () => {
|
||||
expect(isSubpath('Users\\Test\\file.txt', 'Users\\Test')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePath', () => {
|
||||
it('resolves relative paths against the provided base directory', () => {
|
||||
const result = resolvePath('/home/user/project', 'src/main.ts');
|
||||
expect(result).toBe(path.resolve('/home/user/project', 'src/main.ts'));
|
||||
});
|
||||
|
||||
it('resolves relative paths against cwd when baseDir is undefined', () => {
|
||||
const cwd = process.cwd();
|
||||
const result = resolvePath(undefined, 'src/main.ts');
|
||||
expect(result).toBe(path.resolve(cwd, 'src/main.ts'));
|
||||
});
|
||||
|
||||
it('returns absolute paths unchanged', () => {
|
||||
const absolutePath = '/absolute/path/to/file.ts';
|
||||
const result = resolvePath('/some/base', absolutePath);
|
||||
expect(result).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('expands tilde to home directory', () => {
|
||||
const homeDir = os.homedir();
|
||||
const result = resolvePath(undefined, '~');
|
||||
expect(result).toBe(homeDir);
|
||||
});
|
||||
|
||||
it('expands tilde-prefixed paths to home directory', () => {
|
||||
const homeDir = os.homedir();
|
||||
const result = resolvePath(undefined, '~/documents/file.txt');
|
||||
expect(result).toBe(path.join(homeDir, 'documents/file.txt'));
|
||||
});
|
||||
|
||||
it('uses baseDir when provided for relative paths', () => {
|
||||
const baseDir = '/custom/base';
|
||||
const result = resolvePath(baseDir, './relative/path');
|
||||
expect(result).toBe(path.resolve(baseDir, './relative/path'));
|
||||
});
|
||||
|
||||
it('handles tilde expansion regardless of baseDir', () => {
|
||||
const homeDir = os.homedir();
|
||||
const result = resolvePath('/some/base', '~/file.txt');
|
||||
expect(result).toBe(path.join(homeDir, 'file.txt'));
|
||||
});
|
||||
|
||||
it('handles dot paths correctly', () => {
|
||||
const result = resolvePath('/base/dir', '.');
|
||||
expect(result).toBe(path.resolve('/base/dir', '.'));
|
||||
});
|
||||
|
||||
it('handles parent directory references', () => {
|
||||
const result = resolvePath('/base/dir/subdir', '..');
|
||||
expect(result).toBe(path.resolve('/base/dir/subdir', '..'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath', () => {
|
||||
let workspaceRoot: string;
|
||||
let config: Config;
|
||||
|
||||
beforeAll(() => {
|
||||
workspaceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'validate-path-test-'),
|
||||
);
|
||||
fs.mkdirSync(path.join(workspaceRoot, 'subdir'));
|
||||
config = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('validates paths within workspace boundaries', () => {
|
||||
const validPath = path.join(workspaceRoot, 'subdir');
|
||||
expect(() => validatePath(config, validPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when path is outside workspace boundaries', () => {
|
||||
const outsidePath = path.join(os.tmpdir(), 'outside');
|
||||
expect(() => validatePath(config, outsidePath)).toThrowError(
|
||||
/Path is not within workspace/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when path does not exist', () => {
|
||||
const nonExistentPath = path.join(workspaceRoot, 'nonexistent');
|
||||
expect(() => validatePath(config, nonExistentPath)).toThrowError(
|
||||
/Path does not exist:/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when path is a file, not a directory (default behavior)', () => {
|
||||
const filePath = path.join(workspaceRoot, 'test-file.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
expect(() => validatePath(config, filePath)).toThrowError(
|
||||
/Path is not a directory/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows files when allowFiles option is true', () => {
|
||||
const filePath = path.join(workspaceRoot, 'test-file.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
expect(() =>
|
||||
validatePath(config, filePath, { allowFiles: true }),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates paths at workspace root', () => {
|
||||
expect(() => validatePath(config, workspaceRoot)).not.toThrow();
|
||||
});
|
||||
|
||||
it('validates paths in allowed directories', () => {
|
||||
const extraDir = fs.mkdtempSync(path.join(os.tmpdir(), 'validate-extra-'));
|
||||
try {
|
||||
const configWithExtra = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot, extraDir],
|
||||
});
|
||||
expect(() => validatePath(configWithExtra, extraDir)).not.toThrow();
|
||||
} finally {
|
||||
fs.rmSync(extraDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAndValidatePath', () => {
|
||||
let workspaceRoot: string;
|
||||
let config: Config;
|
||||
|
||||
beforeAll(() => {
|
||||
workspaceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'resolve-and-validate-'),
|
||||
);
|
||||
fs.mkdirSync(path.join(workspaceRoot, 'subdir'));
|
||||
config = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the target directory when no path is provided', () => {
|
||||
expect(resolveAndValidatePath(config)).toBe(workspaceRoot);
|
||||
});
|
||||
|
||||
it('resolves relative paths within the workspace', () => {
|
||||
const expected = path.join(workspaceRoot, 'subdir');
|
||||
expect(resolveAndValidatePath(config, 'subdir')).toBe(expected);
|
||||
});
|
||||
|
||||
it('allows absolute paths that are permitted by the workspace context', () => {
|
||||
const extraDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'resolve-and-validate-extra-'),
|
||||
);
|
||||
try {
|
||||
const configWithExtra = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot, extraDir],
|
||||
});
|
||||
expect(resolveAndValidatePath(configWithExtra, extraDir)).toBe(extraDir);
|
||||
} finally {
|
||||
fs.rmSync(extraDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('expands tilde-prefixed paths using the home directory', () => {
|
||||
const fakeHome = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'resolve-and-validate-home-'),
|
||||
);
|
||||
const homeSubdir = path.join(fakeHome, 'project');
|
||||
fs.mkdirSync(homeSubdir);
|
||||
|
||||
const homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(fakeHome);
|
||||
try {
|
||||
const configWithHome = createConfigStub({
|
||||
targetDir: workspaceRoot,
|
||||
allowedDirectories: [workspaceRoot, fakeHome],
|
||||
});
|
||||
expect(resolveAndValidatePath(configWithHome, '~/project')).toBe(
|
||||
homeSubdir,
|
||||
);
|
||||
expect(resolveAndValidatePath(configWithHome, '~')).toBe(fakeHome);
|
||||
} finally {
|
||||
homedirSpy.mockRestore();
|
||||
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the path resolves outside of the workspace', () => {
|
||||
expect(() => resolveAndValidatePath(config, '../outside')).toThrowError(
|
||||
/Path is not within workspace/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the path does not exist', () => {
|
||||
expect(() => resolveAndValidatePath(config, 'missing')).toThrowError(
|
||||
/Path does not exist:/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the path points to a file (default behavior)', () => {
|
||||
const filePath = path.join(workspaceRoot, 'file.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
expect(() => resolveAndValidatePath(config, 'file.txt')).toThrowError(
|
||||
`Path is not a directory: ${filePath}`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows file paths when allowFiles option is true', () => {
|
||||
const filePath = path.join(workspaceRoot, 'file.txt');
|
||||
fs.writeFileSync(filePath, 'content');
|
||||
try {
|
||||
const result = resolveAndValidatePath(config, 'file.txt', {
|
||||
allowFiles: true,
|
||||
});
|
||||
expect(result).toBe(filePath);
|
||||
} finally {
|
||||
fs.rmSync(filePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { isNodeError } from './errors.js';
|
||||
|
||||
export const QWEN_DIR = '.qwen';
|
||||
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
||||
@@ -191,3 +194,93 @@ export function isSubpath(parentPath: string, childPath: string): boolean {
|
||||
!pathModule.isAbsolute(relative)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path with tilde (~) expansion and relative path resolution.
|
||||
* Handles tilde expansion for home directory and resolves relative paths
|
||||
* against the provided base directory or current working directory.
|
||||
*
|
||||
* @param baseDir The base directory to resolve relative paths against (defaults to current working directory)
|
||||
* @param relativePath The path to resolve (can be relative, absolute, or tilde-prefixed)
|
||||
* @returns The resolved absolute path
|
||||
*/
|
||||
export function resolvePath(
|
||||
baseDir: string | undefined = process.cwd(),
|
||||
relativePath: string,
|
||||
): string {
|
||||
const homeDir = os.homedir();
|
||||
|
||||
if (relativePath === '~') {
|
||||
return homeDir;
|
||||
} else if (relativePath.startsWith('~/')) {
|
||||
return path.join(homeDir, relativePath.slice(2));
|
||||
} else if (path.isAbsolute(relativePath)) {
|
||||
return relativePath;
|
||||
} else {
|
||||
return path.resolve(baseDir, relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PathValidationOptions {
|
||||
/**
|
||||
* If true, allows both files and directories. If false (default), only allows directories.
|
||||
*/
|
||||
allowFiles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a resolved path exists within the workspace boundaries.
|
||||
*
|
||||
* @param config The configuration object containing workspace context
|
||||
* @param resolvedPath The absolute path to validate
|
||||
* @param options Validation options
|
||||
* @throws Error if the path is outside workspace boundaries, doesn't exist, or is not a directory (when allowFiles is false)
|
||||
*/
|
||||
export function validatePath(
|
||||
config: Config,
|
||||
resolvedPath: string,
|
||||
options: PathValidationOptions = {},
|
||||
): void {
|
||||
const { allowFiles = false } = options;
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
|
||||
if (!workspaceContext.isPathWithinWorkspace(resolvedPath)) {
|
||||
throw new Error('Path is not within workspace');
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!allowFiles && !stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${resolvedPath}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
throw new Error(`Path does not exist: ${resolvedPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path relative to the workspace root and verifies that it exists
|
||||
* within the workspace boundaries defined in the config.
|
||||
*
|
||||
* @param config The configuration object
|
||||
* @param relativePath The relative path to resolve (optional, defaults to target directory)
|
||||
* @param options Validation options (e.g., allowFiles to permit file paths)
|
||||
*/
|
||||
export function resolveAndValidatePath(
|
||||
config: Config,
|
||||
relativePath?: string,
|
||||
options: PathValidationOptions = {},
|
||||
): string {
|
||||
const targetDir = config.getTargetDir();
|
||||
|
||||
if (!relativePath) {
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
const resolvedPath = resolvePath(targetDir, relativePath);
|
||||
validatePath(config, resolvedPath, options);
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"displayName": "Qwen Code Companion",
|
||||
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.5",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -85,7 +85,7 @@ const distPackageJson = {
|
||||
bin: {
|
||||
qwen: 'cli.js',
|
||||
},
|
||||
files: ['cli.js', 'vendor', 'README.md', 'LICENSE'],
|
||||
files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE'],
|
||||
config: rootPackageJson.config,
|
||||
dependencies: runtimeDependencies,
|
||||
optionalDependencies: {
|
||||
|
||||
@@ -69,7 +69,14 @@ if (process.env.DEBUG) {
|
||||
// than the relaunched process making it harder to debug.
|
||||
env.GEMINI_CLI_NO_RELAUNCH = 'true';
|
||||
}
|
||||
const child = spawn('node', nodeArgs, { stdio: 'inherit', env });
|
||||
// Use process.cwd() to inherit the working directory from launch.json cwd setting
|
||||
// This allows debugging from a specific directory (e.g., .todo)
|
||||
const workingDir = process.env.QWEN_WORKING_DIR || process.cwd();
|
||||
const child = spawn('node', nodeArgs, {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
cwd: workingDir,
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
process.exit(code);
|
||||
|
||||
Reference in New Issue
Block a user