mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-29 13:09:18 +00:00
Compare commits
6 Commits
mingholy/f
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a472e4fcf | ||
|
|
5390f662fc | ||
|
|
c3d427730e | ||
|
|
21fba6eb89 | ||
|
|
d17c37af7d | ||
|
|
82170e96c6 |
@@ -160,9 +160,30 @@ Settings are organized into categories. All settings should be placed within the
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`model.chatCompression.contextPercentageThreshold`** (number):
|
||||
- **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit.
|
||||
- **Description:** Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely.
|
||||
- **Default:** `0.7`
|
||||
|
||||
- **`model.generationConfig`** (object):
|
||||
- **Description:** Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, and `disableCacheControl`, along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults.
|
||||
- **Default:** `undefined`
|
||||
- **Example:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": {
|
||||
"generationConfig": {
|
||||
"timeout": 60000,
|
||||
"disableCacheControl": false,
|
||||
"samplingParams": {
|
||||
"temperature": 0.2,
|
||||
"top_p": 0.8,
|
||||
"max_tokens": 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`model.skipNextSpeakerCheck`** (boolean):
|
||||
- **Description:** Skip the next speaker check.
|
||||
- **Default:** `false`
|
||||
@@ -171,6 +192,10 @@ 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.skipStartupContext`** (boolean):
|
||||
- **Description:** Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup.
|
||||
- **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`
|
||||
@@ -266,6 +291,21 @@ Settings are organized into categories. All settings should be placed within the
|
||||
- **Description:** Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.enableToolOutputTruncation`** (boolean):
|
||||
- **Description:** Enable truncation of large tool outputs.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputThreshold`** (number):
|
||||
- **Description:** Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
|
||||
- **Default:** `25000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputLines`** (number):
|
||||
- **Description:** Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools.
|
||||
- **Default:** `1000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `mcp`
|
||||
|
||||
- **`mcp.serverCommand`** (string):
|
||||
|
||||
@@ -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.5",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -555,7 +555,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -579,7 +578,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2120,7 +2118,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -3282,7 +3279,6 @@
|
||||
"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",
|
||||
@@ -3721,7 +3717,6 @@
|
||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -3732,7 +3727,6 @@
|
||||
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
@@ -3938,7 +3932,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -4707,7 +4700,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5062,7 +5054,8 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/array-includes": {
|
||||
"version": "3.1.9",
|
||||
@@ -6227,6 +6220,7 @@
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
@@ -7260,7 +7254,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7730,6 +7723,7 @@
|
||||
"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",
|
||||
@@ -7791,6 +7785,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -7800,6 +7795,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -7809,6 +7805,7 @@
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -7975,6 +7972,7 @@
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
@@ -7993,6 +7991,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
@@ -8001,13 +8000,15 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/finalhandler/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
@@ -9046,7 +9047,6 @@
|
||||
"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,6 +10950,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -12157,7 +12158,8 @@
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "3.0.0",
|
||||
@@ -12661,7 +12663,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -12672,7 +12673,6 @@
|
||||
"integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -12706,7 +12706,6 @@
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@@ -14516,7 +14515,6 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -14690,8 +14688,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -14699,7 +14696,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -14884,7 +14880,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15154,6 +15149,7 @@
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
@@ -15209,7 +15205,6 @@
|
||||
"integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
@@ -15323,7 +15318,6 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15337,7 +15331,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -16016,7 +16009,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -16032,7 +16024,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
@@ -16147,7 +16139,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -16277,7 +16269,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16287,7 +16278,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
@@ -16299,7 +16290,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"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.5"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.1.5"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "1.16.0",
|
||||
|
||||
@@ -791,6 +791,7 @@ export async function loadCliConfig(
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion ?? false,
|
||||
skipLoopDetection: settings.model?.skipLoopDetection ?? false,
|
||||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
vlmSwitchMode,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
|
||||
@@ -131,6 +131,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
sessionTokenLimit: 'model.sessionTokenLimit',
|
||||
contentGenerator: 'model.generationConfig',
|
||||
skipLoopDetection: 'model.skipLoopDetection',
|
||||
skipStartupContext: 'model.skipStartupContext',
|
||||
enableOpenAILogging: 'model.enableOpenAILogging',
|
||||
tavilyApiKey: 'advanced.tavilyApiKey',
|
||||
vlmSwitchMode: 'experimental.vlmSwitchMode',
|
||||
|
||||
@@ -549,6 +549,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||
showInDialog: true,
|
||||
},
|
||||
skipStartupContext: {
|
||||
type: 'boolean',
|
||||
label: 'Skip Startup Context',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Avoid sending the workspace startup context at the beginning of each session.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableOpenAILogging: {
|
||||
type: 'boolean',
|
||||
label: 'Enable OpenAI Logging',
|
||||
|
||||
@@ -551,6 +551,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[visionSwitchResolver],
|
||||
);
|
||||
|
||||
// onDebugMessage should log to console, not update footer debugMessage
|
||||
const onDebugMessage = useCallback((message: string) => {
|
||||
console.debug(message);
|
||||
}, []);
|
||||
|
||||
const performMemoryRefresh = useCallback(async () => {
|
||||
historyManager.addItem(
|
||||
{
|
||||
@@ -628,7 +633,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
historyManager.addItem,
|
||||
config,
|
||||
settings,
|
||||
setDebugMessage,
|
||||
onDebugMessage,
|
||||
handleSlashCommand,
|
||||
shellModeActive,
|
||||
() => settings.merged.general?.preferredEditor as EditorType,
|
||||
|
||||
@@ -8,38 +8,22 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { aboutCommand } from './aboutCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import * as versionUtils from '../../utils/version.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import * as systemInfoUtils from '../../utils/systemInfo.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../utils/systemInfo.js');
|
||||
|
||||
describe('aboutCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalPlatform = process.platform;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn(),
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
@@ -56,21 +40,25 @@ describe('aboutCommand', () => {
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
|
||||
vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue(
|
||||
'test-model',
|
||||
);
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -81,30 +69,55 @@ describe('aboutCommand', () => {
|
||||
});
|
||||
|
||||
it('should call addItem with all version info', async () => {
|
||||
process.env['SANDBOX'] = '';
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(systemInfoUtils.getExtendedSystemInfo).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
expect.objectContaining({
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: 'test-version',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-gcp-project',
|
||||
ideClient: 'test-ide',
|
||||
},
|
||||
systemInfo: expect.objectContaining({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the correct sandbox environment variable', async () => {
|
||||
process.env['SANDBOX'] = 'gemini-sandbox';
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -113,15 +126,32 @@ describe('aboutCommand', () => {
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
type: MessageType.ABOUT,
|
||||
systemInfo: expect.objectContaining({
|
||||
sandboxEnv: 'gemini-sandbox',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show sandbox-exec profile when applicable', async () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
process.env['SEATBELT_PROFILE'] = 'test-profile';
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -130,18 +160,31 @@ describe('aboutCommand', () => {
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
systemInfo: expect.objectContaining({
|
||||
sandboxEnv: 'sandbox-exec (test-profile)',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show ide client when it is not detected', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
process.env['SANDBOX'] = '';
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
@@ -151,13 +194,87 @@ describe('aboutCommand', () => {
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: 'test-version',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-gcp-project',
|
||||
ideClient: '',
|
||||
systemInfo: expect.objectContaining({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show unknown npmVersion when npm command fails', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: 'unknown',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemInfo: expect.objectContaining({
|
||||
npmVersion: 'unknown',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show unknown sessionId when config is not available', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'Unknown',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: '',
|
||||
sessionId: 'unknown',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
});
|
||||
|
||||
if (!aboutCommand.action) {
|
||||
throw new Error('The about command must have an action.');
|
||||
}
|
||||
|
||||
await aboutCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemInfo: expect.objectContaining({
|
||||
sessionId: 'unknown',
|
||||
}),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -4,53 +4,23 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import type { CommandContext, SlashCommand } from './types.js';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import process from 'node:process';
|
||||
import { MessageType, type HistoryItemAbout } from '../types.js';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
|
||||
export const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
description: 'show version info',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context) => {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env['SANDBOX'];
|
||||
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion,
|
||||
osVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
gcpProject,
|
||||
ideClient,
|
||||
systemInfo,
|
||||
};
|
||||
|
||||
context.ui.addItem(aboutItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
|
||||
@@ -8,41 +8,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import open from 'open';
|
||||
import { bugCommand } from './bugCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import * as systemInfoUtils from '../../utils/systemInfo.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('open');
|
||||
vi.mock('../../utils/version.js');
|
||||
vi.mock('../utils/formatters.js');
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: () => ({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('node:process', () => ({
|
||||
default: {
|
||||
platform: 'test-platform',
|
||||
version: 'v20.0.0',
|
||||
// Keep other necessary process properties if needed by other parts of the code
|
||||
env: process.env,
|
||||
memoryUsage: () => ({ rss: 0 }),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/systemInfo.js');
|
||||
|
||||
describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: '0.1.0',
|
||||
osPlatform: 'test-platform',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test',
|
||||
modelVersion: 'qwen3-coder-plus',
|
||||
selectedAuthType: '',
|
||||
ideClient: 'VSCode',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
gitCommit:
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined,
|
||||
});
|
||||
vi.stubEnv('SANDBOX', 'qwen-test');
|
||||
});
|
||||
|
||||
@@ -55,19 +48,7 @@ describe('bugCommand', () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -75,14 +56,21 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A test bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:**
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Auth Method:**
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
@@ -99,19 +87,7 @@ describe('bugCommand', () => {
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -119,14 +95,21 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'A custom bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:**
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Auth Method:**
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
@@ -138,25 +121,30 @@ describe('bugCommand', () => {
|
||||
});
|
||||
|
||||
it('should include Base URL when auth type is OpenAI', async () => {
|
||||
vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({
|
||||
cliVersion: '0.1.0',
|
||||
osPlatform: 'test-platform',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test',
|
||||
modelVersion: 'qwen3-coder-plus',
|
||||
selectedAuthType: AuthType.USE_OPENAI,
|
||||
ideClient: 'VSCode',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
gitCommit:
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => true,
|
||||
getSessionId: () => 'test-session-id',
|
||||
getContentGeneratorConfig: () => ({
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
}),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: AuthType.USE_OPENAI,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -164,15 +152,22 @@ describe('bugCommand', () => {
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(mockContext, 'OpenAI bug');
|
||||
|
||||
const gitCommitLine =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? `* **Git Commit:** ${GIT_COMMIT_INFO}\n`
|
||||
: '';
|
||||
const expectedInfo = `
|
||||
* **CLI Version:** 0.1.0
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
${gitCommitLine}* **Model:** qwen3-coder-plus
|
||||
* **Sandbox:** test
|
||||
* **OS Platform:** test-platform
|
||||
* **OS Arch:** x64
|
||||
* **OS Release:** 22.0.0
|
||||
* **Node.js Version:** v20.0.0
|
||||
* **NPM Version:** 10.0.0
|
||||
* **Session ID:** test-session-id
|
||||
* **Operating System:** test-platform v20.0.0
|
||||
* **Sandbox Environment:** test
|
||||
* **Auth Type:** ${AuthType.USE_OPENAI}
|
||||
* **Auth Method:** ${AuthType.USE_OPENAI}
|
||||
* **Base URL:** https://api.openai.com/v1
|
||||
* **Model Version:** qwen3-coder-plus
|
||||
* **Memory Usage:** 100 MB
|
||||
* **IDE Client:** VSCode
|
||||
`;
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
*/
|
||||
|
||||
import open from 'open';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { getExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
name: 'bug',
|
||||
@@ -23,50 +23,20 @@ export const bugCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args?: string): Promise<void> => {
|
||||
const bugDescription = (args || '').trim();
|
||||
const { config } = context.services;
|
||||
const systemInfo = await getExtendedSystemInfo(context);
|
||||
|
||||
const osVersion = `${process.platform} ${process.version}`;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
|
||||
sandboxEnv = process.env['SANDBOX'].replace(/^qwen-(?:code-)?/, '');
|
||||
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
|
||||
sandboxEnv = `sandbox-exec (${
|
||||
process.env['SEATBELT_PROFILE'] || 'unknown'
|
||||
})`;
|
||||
}
|
||||
const modelVersion = config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const baseUrl =
|
||||
selectedAuthType === AuthType.USE_OPENAI
|
||||
? config?.getContentGeneratorConfig()?.baseUrl
|
||||
: undefined;
|
||||
const fields = getSystemInfoFields(systemInfo);
|
||||
|
||||
let info = `
|
||||
* **CLI Version:** ${cliVersion}
|
||||
* **Git Commit:** ${GIT_COMMIT_INFO}
|
||||
* **Session ID:** ${config?.getSessionId() || 'unknown'}
|
||||
* **Operating System:** ${osVersion}
|
||||
* **Sandbox Environment:** ${sandboxEnv}
|
||||
* **Auth Type:** ${selectedAuthType}`;
|
||||
if (baseUrl) {
|
||||
info += `\n* **Base URL:** ${baseUrl}`;
|
||||
}
|
||||
info += `
|
||||
* **Model Version:** ${modelVersion}
|
||||
* **Memory Usage:** ${memoryUsage}
|
||||
`;
|
||||
if (ideClient) {
|
||||
info += `* **IDE Client:** ${ideClient}\n`;
|
||||
// Generate bug report info using the same field configuration
|
||||
let info = '\n';
|
||||
for (const field of fields) {
|
||||
info += `* **${field.label}:** ${getFieldValue(field, systemInfo)}\n`;
|
||||
}
|
||||
|
||||
let bugReportUrl =
|
||||
'https://github.com/QwenLM/qwen-code/issues/new?template=bug_report.yml&title={title}&info={info}';
|
||||
|
||||
const bugCommandSettings = config?.getBugCommand();
|
||||
const bugCommandSettings = context.services.config?.getBugCommand();
|
||||
if (bugCommandSettings?.urlTemplate) {
|
||||
bugReportUrl = bugCommandSettings.urlTemplate;
|
||||
}
|
||||
@@ -98,11 +68,3 @@ export const bugCommand: SlashCommand = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
|
||||
@@ -7,127 +7,46 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import type { ExtendedSystemInfo } from '../../utils/systemInfo.js';
|
||||
import {
|
||||
getSystemInfoFields,
|
||||
getFieldValue,
|
||||
type SystemInfoField,
|
||||
} from '../../utils/systemInfoFields.js';
|
||||
|
||||
interface AboutBoxProps {
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
}
|
||||
type AboutBoxProps = ExtendedSystemInfo;
|
||||
|
||||
export const AboutBox: React.FC<AboutBoxProps> = ({
|
||||
cliVersion,
|
||||
osVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
gcpProject,
|
||||
ideClient,
|
||||
}) => (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
About Qwen Code
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
CLI Version
|
||||
export const AboutBox: React.FC<AboutBoxProps> = (props) => {
|
||||
const fields = getSystemInfoFields(props);
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
marginY={1}
|
||||
width="100%"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.accent}>
|
||||
About Qwen Code
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{cliVersion}</Text>
|
||||
</Box>
|
||||
{fields.map((field: SystemInfoField) => (
|
||||
<Box key={field.key} flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
{field.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{getFieldValue(field, props)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO) && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Git Commit
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{GIT_COMMIT_INFO}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Model
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{modelVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Sandbox
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{sandboxEnv}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
OS
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{osVersion}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
Auth Method
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{selectedAuthType.startsWith('oauth') ? 'OAuth' : selectedAuthType}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{gcpProject && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
GCP Project
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{gcpProject}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{ideClient && (
|
||||
<Box flexDirection="row">
|
||||
<Box width="35%">
|
||||
<Text bold color={theme.text.link}>
|
||||
IDE Client
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>{ideClient}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,15 +71,24 @@ describe('<HistoryItemDisplay />', () => {
|
||||
|
||||
it('renders AboutBox for "about" type', () => {
|
||||
const item: HistoryItem = {
|
||||
...baseItem,
|
||||
id: 1,
|
||||
type: MessageType.ABOUT,
|
||||
cliVersion: '1.0.0',
|
||||
osVersion: 'test-os',
|
||||
sandboxEnv: 'test-env',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
gcpProject: 'test-project',
|
||||
ideClient: 'test-ide',
|
||||
systemInfo: {
|
||||
cliVersion: '1.0.0',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'test-env',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
gitCommit: undefined,
|
||||
},
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<HistoryItemDisplay {...baseItem} item={item} />,
|
||||
|
||||
@@ -95,15 +95,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
|
||||
<ErrorMessage text={itemForDisplay.text} />
|
||||
)}
|
||||
{itemForDisplay.type === 'about' && (
|
||||
<AboutBox
|
||||
cliVersion={itemForDisplay.cliVersion}
|
||||
osVersion={itemForDisplay.osVersion}
|
||||
sandboxEnv={itemForDisplay.sandboxEnv}
|
||||
modelVersion={itemForDisplay.modelVersion}
|
||||
selectedAuthType={itemForDisplay.selectedAuthType}
|
||||
gcpProject={itemForDisplay.gcpProject}
|
||||
ideClient={itemForDisplay.ideClient}
|
||||
/>
|
||||
<AboutBox {...itemForDisplay.systemInfo} />
|
||||
)}
|
||||
{itemForDisplay.type === 'help' && commands && (
|
||||
<Help commands={commands} />
|
||||
|
||||
@@ -80,6 +80,8 @@ describe('handleAtCommand', () => {
|
||||
getReadManyFilesExcludes: () => [],
|
||||
}),
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as unknown as Config;
|
||||
|
||||
const registry = new ToolRegistry(mockConfig);
|
||||
|
||||
@@ -138,13 +138,7 @@ export const useSlashCommandProcessor = (
|
||||
if (message.type === MessageType.ABOUT) {
|
||||
historyItemContent = {
|
||||
type: 'about',
|
||||
cliVersion: message.cliVersion,
|
||||
osVersion: message.osVersion,
|
||||
sandboxEnv: message.sandboxEnv,
|
||||
modelVersion: message.modelVersion,
|
||||
selectedAuthType: message.selectedAuthType,
|
||||
gcpProject: message.gcpProject,
|
||||
ideClient: message.ideClient,
|
||||
systemInfo: message.systemInfo,
|
||||
};
|
||||
} else if (message.type === MessageType.HELP) {
|
||||
historyItemContent = {
|
||||
|
||||
@@ -120,13 +120,22 @@ export type HistoryItemWarning = HistoryItemBase & {
|
||||
|
||||
export type HistoryItemAbout = HistoryItemBase & {
|
||||
type: 'about';
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
systemInfo: {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type HistoryItemHelp = HistoryItemBase & {
|
||||
@@ -288,13 +297,22 @@ export type Message =
|
||||
| {
|
||||
type: MessageType.ABOUT;
|
||||
timestamp: Date;
|
||||
cliVersion: string;
|
||||
osVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
gcpProject: string;
|
||||
ideClient: string;
|
||||
systemInfo: {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
};
|
||||
content?: string; // Optional content, not really used for ABOUT
|
||||
}
|
||||
| {
|
||||
|
||||
331
packages/cli/src/utils/systemInfo.test.ts
Normal file
331
packages/cli/src/utils/systemInfo.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getSystemInfo,
|
||||
getExtendedSystemInfo,
|
||||
getNpmVersion,
|
||||
getSandboxEnv,
|
||||
getIdeClientName,
|
||||
} from './systemInfo.js';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
|
||||
import * as child_process from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
import * as versionUtils from './version.js';
|
||||
import type { ExecSyncOptions } from 'node:child_process';
|
||||
|
||||
vi.mock('node:child_process');
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
default: {
|
||||
release: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('systemInfo', () => {
|
||||
let mockContext: CommandContext;
|
||||
const originalPlatform = process.platform;
|
||||
const originalArch = process.arch;
|
||||
const originalVersion = process.version;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn().mockReturnValue('test-model'),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
baseUrl: 'https://api.openai.com',
|
||||
}),
|
||||
},
|
||||
settings: {
|
||||
merged: {
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: 'test-auth',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
|
||||
vi.mocked(versionUtils.getCliVersion).mockResolvedValue('test-version');
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
vi.mocked(os.release).mockReturnValue('22.0.0');
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project';
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
});
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: 'x64',
|
||||
});
|
||||
Object.defineProperty(process, 'version', {
|
||||
value: 'v20.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
Object.defineProperty(process, 'arch', {
|
||||
value: originalArch,
|
||||
});
|
||||
Object.defineProperty(process, 'version', {
|
||||
value: originalVersion,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
vi.clearAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getNpmVersion', () => {
|
||||
it('should return npm version when available', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
const version = await getNpmVersion();
|
||||
expect(version).toBe('10.0.0');
|
||||
});
|
||||
|
||||
it('should return unknown when npm command fails', async () => {
|
||||
vi.mocked(child_process.execSync).mockImplementation(() => {
|
||||
throw new Error('npm not found');
|
||||
});
|
||||
const version = await getNpmVersion();
|
||||
expect(version).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSandboxEnv', () => {
|
||||
it('should return "no sandbox" when SANDBOX is not set', () => {
|
||||
delete process.env['SANDBOX'];
|
||||
expect(getSandboxEnv()).toBe('no sandbox');
|
||||
});
|
||||
|
||||
it('should return sandbox-exec info when SANDBOX is sandbox-exec', () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
process.env['SEATBELT_PROFILE'] = 'test-profile';
|
||||
expect(getSandboxEnv()).toBe('sandbox-exec (test-profile)');
|
||||
});
|
||||
|
||||
it('should return sandbox name without prefix when stripPrefix is true', () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
expect(getSandboxEnv(true)).toBe('test-sandbox');
|
||||
});
|
||||
|
||||
it('should return sandbox name with prefix when stripPrefix is false', () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
expect(getSandboxEnv(false)).toBe('qwen-code-test-sandbox');
|
||||
});
|
||||
|
||||
it('should handle qwen- prefix removal', () => {
|
||||
process.env['SANDBOX'] = 'qwen-custom-sandbox';
|
||||
expect(getSandboxEnv(true)).toBe('custom-sandbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdeClientName', () => {
|
||||
it('should return IDE client name when IDE mode is enabled', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('test-ide');
|
||||
});
|
||||
|
||||
it('should return empty string when IDE mode is disabled', async () => {
|
||||
vi.mocked(mockContext.services.config!.getIdeMode).mockReturnValue(false);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when IDE client detection fails', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockRejectedValue(
|
||||
new Error('IDE client error'),
|
||||
);
|
||||
|
||||
const ideClient = await getIdeClientName(mockContext);
|
||||
expect(ideClient).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemInfo', () => {
|
||||
it('should collect all system information', async () => {
|
||||
// Ensure SANDBOX is not set for this test
|
||||
delete process.env['SANDBOX'];
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const systemInfo = await getSystemInfo(mockContext);
|
||||
|
||||
expect(systemInfo).toEqual({
|
||||
cliVersion: 'test-version',
|
||||
osPlatform: 'test-os',
|
||||
osArch: 'x64',
|
||||
osRelease: '22.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing config gracefully', async () => {
|
||||
mockContext.services.config = null;
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
|
||||
const systemInfo = await getSystemInfo(mockContext);
|
||||
|
||||
expect(systemInfo.modelVersion).toBe('Unknown');
|
||||
expect(systemInfo.sessionId).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtendedSystemInfo', () => {
|
||||
it('should include memory usage and base URL', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const { AuthType } = await import('@qwen-code/qwen-code-core');
|
||||
// Update the mock context to use OpenAI auth
|
||||
mockContext.services.settings.merged.security!.auth!.selectedType =
|
||||
AuthType.USE_OPENAI;
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.memoryUsage).toBeDefined();
|
||||
expect(extendedInfo.memoryUsage).toMatch(/\d+\.\d+ (KB|MB|GB)/);
|
||||
expect(extendedInfo.baseUrl).toBe('https://api.openai.com');
|
||||
});
|
||||
|
||||
it('should use sandbox env without prefix for bug reports', async () => {
|
||||
process.env['SANDBOX'] = 'qwen-code-test-sandbox';
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.sandboxEnv).toBe('test-sandbox');
|
||||
});
|
||||
|
||||
it('should not include base URL for non-OpenAI auth', async () => {
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(''),
|
||||
} as unknown as IdeClient);
|
||||
vi.mocked(child_process.execSync).mockImplementation(
|
||||
(command: string, options?: ExecSyncOptions) => {
|
||||
if (
|
||||
options &&
|
||||
typeof options === 'object' &&
|
||||
'encoding' in options &&
|
||||
options.encoding === 'utf-8'
|
||||
) {
|
||||
return '10.0.0';
|
||||
}
|
||||
return Buffer.from('10.0.0', 'utf-8');
|
||||
},
|
||||
);
|
||||
|
||||
const extendedInfo = await getExtendedSystemInfo(mockContext);
|
||||
|
||||
expect(extendedInfo.baseUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
173
packages/cli/src/utils/systemInfo.ts
Normal file
173
packages/cli/src/utils/systemInfo.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import process from 'node:process';
|
||||
import os from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import type { CommandContext } from '../ui/commands/types.js';
|
||||
import { getCliVersion } from './version.js';
|
||||
import { IdeClient, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import { formatMemoryUsage } from '../ui/utils/formatters.js';
|
||||
import { GIT_COMMIT_INFO } from '../generated/git-commit.js';
|
||||
|
||||
/**
|
||||
* System information interface containing all system-related details
|
||||
* that can be collected for debugging and reporting purposes.
|
||||
*/
|
||||
export interface SystemInfo {
|
||||
cliVersion: string;
|
||||
osPlatform: string;
|
||||
osArch: string;
|
||||
osRelease: string;
|
||||
nodeVersion: string;
|
||||
npmVersion: string;
|
||||
sandboxEnv: string;
|
||||
modelVersion: string;
|
||||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional system information for bug reports
|
||||
*/
|
||||
export interface ExtendedSystemInfo extends SystemInfo {
|
||||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the NPM version, handling cases where npm might not be available.
|
||||
* Returns 'unknown' if npm command fails or is not found.
|
||||
*/
|
||||
export async function getNpmVersion(): Promise<string> {
|
||||
try {
|
||||
return execSync('npm --version', { encoding: 'utf-8' }).trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IDE client name if IDE mode is enabled.
|
||||
* Returns empty string if IDE mode is disabled or IDE client is not detected.
|
||||
*/
|
||||
export async function getIdeClientName(
|
||||
context: CommandContext,
|
||||
): Promise<string> {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sandbox environment information.
|
||||
* Handles different sandbox types including sandbox-exec and custom sandbox environments.
|
||||
* For bug reports, removes 'qwen-' or 'qwen-code-' prefixes from sandbox names.
|
||||
*
|
||||
* @param stripPrefix - Whether to strip 'qwen-' prefix (used for bug reports)
|
||||
*/
|
||||
export function getSandboxEnv(stripPrefix = false): string {
|
||||
const sandbox = process.env['SANDBOX'];
|
||||
|
||||
if (!sandbox || sandbox === 'sandbox-exec') {
|
||||
if (sandbox === 'sandbox-exec') {
|
||||
const profile = process.env['SEATBELT_PROFILE'] || 'unknown';
|
||||
return `sandbox-exec (${profile})`;
|
||||
}
|
||||
return 'no sandbox';
|
||||
}
|
||||
|
||||
// For bug reports, remove qwen- prefix
|
||||
if (stripPrefix) {
|
||||
return sandbox.replace(/^qwen-(?:code-)?/, '');
|
||||
}
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects comprehensive system information for debugging and reporting.
|
||||
* This function gathers all system-related details including OS, versions,
|
||||
* sandbox environment, authentication, and session information.
|
||||
*
|
||||
* @param context - Command context containing config and settings
|
||||
* @returns Promise resolving to SystemInfo object with all collected information
|
||||
*/
|
||||
export async function getSystemInfo(
|
||||
context: CommandContext,
|
||||
): Promise<SystemInfo> {
|
||||
const osPlatform = process.platform;
|
||||
const osArch = process.arch;
|
||||
const osRelease = os.release();
|
||||
const nodeVersion = process.version;
|
||||
const npmVersion = await getNpmVersion();
|
||||
const sandboxEnv = getSandboxEnv();
|
||||
const modelVersion = context.services.config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const sessionId = context.services.config?.getSessionId() || 'unknown';
|
||||
|
||||
return {
|
||||
cliVersion,
|
||||
osPlatform,
|
||||
osArch,
|
||||
osRelease,
|
||||
nodeVersion,
|
||||
npmVersion,
|
||||
sandboxEnv,
|
||||
modelVersion,
|
||||
selectedAuthType,
|
||||
ideClient,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects extended system information for bug reports.
|
||||
* Includes all standard system info plus memory usage and optional base URL.
|
||||
*
|
||||
* @param context - Command context containing config and settings
|
||||
* @returns Promise resolving to ExtendedSystemInfo object
|
||||
*/
|
||||
export async function getExtendedSystemInfo(
|
||||
context: CommandContext,
|
||||
): Promise<ExtendedSystemInfo> {
|
||||
const baseInfo = await getSystemInfo(context);
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
|
||||
// For bug reports, use sandbox name without prefix
|
||||
const sandboxEnv = getSandboxEnv(true);
|
||||
|
||||
// Get base URL if using OpenAI auth
|
||||
const baseUrl =
|
||||
baseInfo.selectedAuthType === AuthType.USE_OPENAI
|
||||
? context.services.config?.getContentGeneratorConfig()?.baseUrl
|
||||
: undefined;
|
||||
|
||||
// Get git commit info
|
||||
const gitCommit =
|
||||
GIT_COMMIT_INFO && !['N/A'].includes(GIT_COMMIT_INFO)
|
||||
? GIT_COMMIT_INFO
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...baseInfo,
|
||||
sandboxEnv,
|
||||
memoryUsage,
|
||||
baseUrl,
|
||||
gitCommit,
|
||||
};
|
||||
}
|
||||
117
packages/cli/src/utils/systemInfoFields.ts
Normal file
117
packages/cli/src/utils/systemInfoFields.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ExtendedSystemInfo } from './systemInfo.js';
|
||||
|
||||
/**
|
||||
* Field configuration for system information display
|
||||
*/
|
||||
export interface SystemInfoField {
|
||||
label: string;
|
||||
key: keyof ExtendedSystemInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified field configuration for system information display.
|
||||
* This ensures consistent labeling between /about and /bug commands.
|
||||
*/
|
||||
export function getSystemInfoFields(
|
||||
info: ExtendedSystemInfo,
|
||||
): SystemInfoField[] {
|
||||
const allFields: SystemInfoField[] = [
|
||||
{
|
||||
label: 'CLI Version',
|
||||
key: 'cliVersion',
|
||||
},
|
||||
{
|
||||
label: 'Git Commit',
|
||||
key: 'gitCommit',
|
||||
},
|
||||
{
|
||||
label: 'Model',
|
||||
key: 'modelVersion',
|
||||
},
|
||||
{
|
||||
label: 'Sandbox',
|
||||
key: 'sandboxEnv',
|
||||
},
|
||||
{
|
||||
label: 'OS Platform',
|
||||
key: 'osPlatform',
|
||||
},
|
||||
{
|
||||
label: 'OS Arch',
|
||||
key: 'osArch',
|
||||
},
|
||||
{
|
||||
label: 'OS Release',
|
||||
key: 'osRelease',
|
||||
},
|
||||
{
|
||||
label: 'Node.js Version',
|
||||
key: 'nodeVersion',
|
||||
},
|
||||
{
|
||||
label: 'NPM Version',
|
||||
key: 'npmVersion',
|
||||
},
|
||||
{
|
||||
label: 'Session ID',
|
||||
key: 'sessionId',
|
||||
},
|
||||
{
|
||||
label: 'Auth Method',
|
||||
key: 'selectedAuthType',
|
||||
},
|
||||
{
|
||||
label: 'Base URL',
|
||||
key: 'baseUrl',
|
||||
},
|
||||
{
|
||||
label: 'Memory Usage',
|
||||
key: 'memoryUsage',
|
||||
},
|
||||
{
|
||||
label: 'IDE Client',
|
||||
key: 'ideClient',
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out optional fields that are not present
|
||||
return allFields.filter((field) => {
|
||||
const value = info[field.key];
|
||||
// Optional fields: only show if they exist and are non-empty
|
||||
if (
|
||||
field.key === 'baseUrl' ||
|
||||
field.key === 'gitCommit' ||
|
||||
field.key === 'ideClient'
|
||||
) {
|
||||
return Boolean(value);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a field from system info
|
||||
*/
|
||||
export function getFieldValue(
|
||||
field: SystemInfoField,
|
||||
info: ExtendedSystemInfo,
|
||||
): string {
|
||||
const value = info[field.key];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Special formatting for selectedAuthType
|
||||
if (field.key === 'selectedAuthType') {
|
||||
return String(value).startsWith('oauth') ? 'OAuth' : String(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"description": "Qwen Code Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -738,13 +738,13 @@ describe('Server Config (config.ts)', () => {
|
||||
|
||||
it('should return the calculated threshold when it is smaller than the default', () => {
|
||||
const config = new Config(baseParams);
|
||||
vi.mocked(tokenLimit).mockReturnValue(32000);
|
||||
vi.mocked(tokenLimit).mockReturnValue(8000);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
1000,
|
||||
2000,
|
||||
);
|
||||
// 4 * (32000 - 1000) = 4 * 31000 = 124000
|
||||
// default is 4_000_000
|
||||
expect(config.getTruncateToolOutputThreshold()).toBe(124000);
|
||||
// 4 * (8000 - 2000) = 4 * 6000 = 24000
|
||||
// default is 25_000
|
||||
expect(config.getTruncateToolOutputThreshold()).toBe(24000);
|
||||
});
|
||||
|
||||
it('should return the default threshold when the calculated value is larger', () => {
|
||||
@@ -754,8 +754,8 @@ describe('Server Config (config.ts)', () => {
|
||||
500_000,
|
||||
);
|
||||
// 4 * (2_000_000 - 500_000) = 4 * 1_500_000 = 6_000_000
|
||||
// default is 4_000_000
|
||||
expect(config.getTruncateToolOutputThreshold()).toBe(4_000_000);
|
||||
// default is 25_000
|
||||
expect(config.getTruncateToolOutputThreshold()).toBe(25_000);
|
||||
});
|
||||
|
||||
it('should use a custom truncateToolOutputThreshold if provided', () => {
|
||||
|
||||
@@ -161,7 +161,7 @@ export interface ExtensionInstallMetadata {
|
||||
autoUpdate?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 4_000_000;
|
||||
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000;
|
||||
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES = 1000;
|
||||
|
||||
export class MCPServerConfig {
|
||||
@@ -288,6 +288,7 @@ export interface ConfigParameters {
|
||||
eventEmitter?: EventEmitter;
|
||||
useSmartEdit?: boolean;
|
||||
output?: OutputSettings;
|
||||
skipStartupContext?: boolean;
|
||||
}
|
||||
|
||||
export class Config {
|
||||
@@ -377,6 +378,7 @@ export class Config {
|
||||
private readonly extensionManagement: boolean = true;
|
||||
private readonly enablePromptCompletion: boolean = false;
|
||||
private readonly skipLoopDetection: boolean;
|
||||
private readonly skipStartupContext: boolean;
|
||||
private readonly vlmSwitchMode: string | undefined;
|
||||
private initialized: boolean = false;
|
||||
readonly storage: Storage;
|
||||
@@ -469,6 +471,7 @@ export class Config {
|
||||
this.interactive = params.interactive ?? false;
|
||||
this.trustedFolder = params.trustedFolder;
|
||||
this.skipLoopDetection = params.skipLoopDetection ?? false;
|
||||
this.skipStartupContext = params.skipStartupContext ?? false;
|
||||
|
||||
// Web search
|
||||
this.webSearch = params.webSearch;
|
||||
@@ -1041,6 +1044,10 @@ export class Config {
|
||||
return this.skipLoopDetection;
|
||||
}
|
||||
|
||||
getSkipStartupContext(): boolean {
|
||||
return this.skipStartupContext;
|
||||
}
|
||||
|
||||
getVlmSwitchMode(): string | undefined {
|
||||
return this.vlmSwitchMode;
|
||||
}
|
||||
@@ -1050,6 +1057,13 @@ export class Config {
|
||||
}
|
||||
|
||||
getTruncateToolOutputThreshold(): number {
|
||||
if (
|
||||
!this.enableToolOutputTruncation ||
|
||||
this.truncateToolOutputThreshold <= 0
|
||||
) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
// Estimate remaining context window in characters (1 token ~= 4 chars).
|
||||
4 *
|
||||
@@ -1060,6 +1074,10 @@ export class Config {
|
||||
}
|
||||
|
||||
getTruncateToolOutputLines(): number {
|
||||
if (!this.enableToolOutputTruncation || this.truncateToolOutputLines <= 0) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
return this.truncateToolOutputLines;
|
||||
}
|
||||
|
||||
|
||||
@@ -1540,6 +1540,268 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreToolScheduler Sequential Execution', () => {
|
||||
it('should execute tool calls in a batch sequentially', async () => {
|
||||
// Arrange
|
||||
let firstCallFinished = false;
|
||||
const executeFn = vi
|
||||
.fn()
|
||||
.mockImplementation(async (args: { call: number }) => {
|
||||
if (args.call === 1) {
|
||||
// First call, wait for a bit to simulate work
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
firstCallFinished = true;
|
||||
return { llmContent: 'First call done' };
|
||||
}
|
||||
if (args.call === 2) {
|
||||
// Second call, should only happen after the first is finished
|
||||
if (!firstCallFinished) {
|
||||
throw new Error(
|
||||
'Second tool call started before the first one finished!',
|
||||
);
|
||||
}
|
||||
return { llmContent: 'Second call done' };
|
||||
}
|
||||
return { llmContent: 'default' };
|
||||
});
|
||||
|
||||
const mockTool = new MockTool({ name: 'mockTool', execute: executeFn });
|
||||
const declarativeTool = mockTool;
|
||||
|
||||
const mockToolRegistry = {
|
||||
getTool: () => declarativeTool,
|
||||
getToolByName: () => declarativeTool,
|
||||
getFunctionDeclarations: () => [],
|
||||
tools: new Map(),
|
||||
discovery: {},
|
||||
registerTool: () => {},
|
||||
getToolByDisplayName: () => declarativeTool,
|
||||
getTools: () => [],
|
||||
discoverTools: async () => {},
|
||||
getAllTools: () => [],
|
||||
getToolsByServer: () => [],
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
const onAllToolCallsComplete = vi.fn();
|
||||
const onToolCallsUpdate = vi.fn();
|
||||
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => ApprovalMode.YOLO, // Use YOLO to avoid confirmation prompts
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
terminalHeight: 30,
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
},
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const requests = [
|
||||
{
|
||||
callId: '1',
|
||||
name: 'mockTool',
|
||||
args: { call: 1 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
{
|
||||
callId: '2',
|
||||
name: 'mockTool',
|
||||
args: { call: 2 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
await scheduler.schedule(requests, abortController.signal);
|
||||
|
||||
// Assert
|
||||
await vi.waitFor(() => {
|
||||
expect(onAllToolCallsComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that execute was called twice
|
||||
expect(executeFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Check the order of calls
|
||||
const calls = executeFn.mock.calls;
|
||||
expect(calls[0][0]).toEqual({ call: 1 });
|
||||
expect(calls[1][0]).toEqual({ call: 2 });
|
||||
|
||||
// The onAllToolCallsComplete should be called once with both results
|
||||
const completedCalls = onAllToolCallsComplete.mock
|
||||
.calls[0][0] as ToolCall[];
|
||||
expect(completedCalls).toHaveLength(2);
|
||||
expect(completedCalls[0].status).toBe('success');
|
||||
expect(completedCalls[1].status).toBe('success');
|
||||
});
|
||||
|
||||
it('should cancel subsequent tools when the signal is aborted.', async () => {
|
||||
// Arrange
|
||||
const abortController = new AbortController();
|
||||
let secondCallStarted = false;
|
||||
|
||||
const executeFn = vi
|
||||
.fn()
|
||||
.mockImplementation(async (args: { call: number }) => {
|
||||
if (args.call === 1) {
|
||||
return { llmContent: 'First call done' };
|
||||
}
|
||||
if (args.call === 2) {
|
||||
secondCallStarted = true;
|
||||
// This call will be cancelled while it's "running".
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
// It should not return a value because it will be cancelled.
|
||||
return { llmContent: 'Second call should not complete' };
|
||||
}
|
||||
if (args.call === 3) {
|
||||
return { llmContent: 'Third call done' };
|
||||
}
|
||||
return { llmContent: 'default' };
|
||||
});
|
||||
|
||||
const mockTool = new MockTool({ name: 'mockTool', execute: executeFn });
|
||||
const declarativeTool = mockTool;
|
||||
|
||||
const mockToolRegistry = {
|
||||
getTool: () => declarativeTool,
|
||||
getToolByName: () => declarativeTool,
|
||||
getFunctionDeclarations: () => [],
|
||||
tools: new Map(),
|
||||
discovery: {},
|
||||
registerTool: () => {},
|
||||
getToolByDisplayName: () => declarativeTool,
|
||||
getTools: () => [],
|
||||
discoverTools: async () => {},
|
||||
getAllTools: () => [],
|
||||
getToolsByServer: () => [],
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
const onAllToolCallsComplete = vi.fn();
|
||||
const onToolCallsUpdate = vi.fn();
|
||||
|
||||
const mockConfig = {
|
||||
getSessionId: () => 'test-session-id',
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
getDebugMode: () => false,
|
||||
getApprovalMode: () => ApprovalMode.YOLO,
|
||||
getAllowedTools: () => [],
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'oauth-personal',
|
||||
}),
|
||||
getShellExecutionConfig: () => ({
|
||||
terminalWidth: 90,
|
||||
terminalHeight: 30,
|
||||
}),
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp',
|
||||
},
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getUseSmartEdit: () => false,
|
||||
getUseModelRouter: () => false,
|
||||
getGeminiClient: () => null,
|
||||
} as unknown as Config;
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
onEditorClose: vi.fn(),
|
||||
});
|
||||
|
||||
const requests = [
|
||||
{
|
||||
callId: '1',
|
||||
name: 'mockTool',
|
||||
args: { call: 1 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
{
|
||||
callId: '2',
|
||||
name: 'mockTool',
|
||||
args: { call: 2 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
{
|
||||
callId: '3',
|
||||
name: 'mockTool',
|
||||
args: { call: 3 },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const schedulePromise = scheduler.schedule(
|
||||
requests,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
// Wait for the second call to start, then abort.
|
||||
await vi.waitFor(() => {
|
||||
expect(secondCallStarted).toBe(true);
|
||||
});
|
||||
abortController.abort();
|
||||
|
||||
await schedulePromise;
|
||||
|
||||
// Assert
|
||||
await vi.waitFor(() => {
|
||||
expect(onAllToolCallsComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that execute was called for all three tools initially
|
||||
expect(executeFn).toHaveBeenCalledTimes(3);
|
||||
expect(executeFn).toHaveBeenCalledWith({ call: 1 });
|
||||
expect(executeFn).toHaveBeenCalledWith({ call: 2 });
|
||||
expect(executeFn).toHaveBeenCalledWith({ call: 3 });
|
||||
|
||||
const completedCalls = onAllToolCallsComplete.mock
|
||||
.calls[0][0] as ToolCall[];
|
||||
expect(completedCalls).toHaveLength(3);
|
||||
|
||||
const call1 = completedCalls.find((c) => c.request.callId === '1');
|
||||
const call2 = completedCalls.find((c) => c.request.callId === '2');
|
||||
const call3 = completedCalls.find((c) => c.request.callId === '3');
|
||||
|
||||
expect(call1?.status).toBe('success');
|
||||
expect(call2?.status).toBe('cancelled');
|
||||
expect(call3?.status).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateAndSaveToFile', () => {
|
||||
const mockWriteFile = vi.mocked(fs.writeFile);
|
||||
const THRESHOLD = 40_000;
|
||||
@@ -1719,14 +1981,14 @@ describe('truncateAndSaveToFile', () => {
|
||||
);
|
||||
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with the absolute file path above',
|
||||
'Tool output was too large and has been truncated',
|
||||
);
|
||||
expect(result.content).toContain('read_file tool with offset=0, limit=100');
|
||||
expect(result.content).toContain('The full output has been saved to:');
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with offset=N to skip N lines',
|
||||
'To read the complete output, use the read_file tool with the absolute file path above',
|
||||
);
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with limit=M to read only M lines',
|
||||
'The truncated output below shows the beginning and end of the content',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -299,10 +299,7 @@ export async function truncateAndSaveToFile(
|
||||
return {
|
||||
content: `Tool output was too large and has been truncated.
|
||||
The full output has been saved to: ${outputFile}
|
||||
To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. For large files, you can use the offset and limit parameters to read specific sections:
|
||||
- ${ReadFileTool.Name} tool with offset=0, limit=100 to see the first 100 lines
|
||||
- ${ReadFileTool.Name} tool with offset=N to skip N lines from the beginning
|
||||
- ${ReadFileTool.Name} tool with limit=M to read only M lines at a time
|
||||
To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above.
|
||||
The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed.
|
||||
This allows you to efficiently examine different parts of the output without loading the entire file.
|
||||
Truncated part of the output:
|
||||
@@ -846,7 +843,7 @@ export class CoreToolScheduler {
|
||||
);
|
||||
}
|
||||
}
|
||||
this.attemptExecutionOfScheduledCalls(signal);
|
||||
await this.attemptExecutionOfScheduledCalls(signal);
|
||||
void this.checkAndNotifyCompletion();
|
||||
} finally {
|
||||
this.isScheduling = false;
|
||||
@@ -921,7 +918,7 @@ export class CoreToolScheduler {
|
||||
}
|
||||
this.setStatusInternal(callId, 'scheduled');
|
||||
}
|
||||
this.attemptExecutionOfScheduledCalls(signal);
|
||||
await this.attemptExecutionOfScheduledCalls(signal);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -967,7 +964,9 @@ export class CoreToolScheduler {
|
||||
});
|
||||
}
|
||||
|
||||
private attemptExecutionOfScheduledCalls(signal: AbortSignal): void {
|
||||
private async attemptExecutionOfScheduledCalls(
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const allCallsFinalOrScheduled = this.toolCalls.every(
|
||||
(call) =>
|
||||
call.status === 'scheduled' ||
|
||||
@@ -981,8 +980,8 @@ export class CoreToolScheduler {
|
||||
(call) => call.status === 'scheduled',
|
||||
);
|
||||
|
||||
callsToExecute.forEach((toolCall) => {
|
||||
if (toolCall.status !== 'scheduled') return;
|
||||
for (const toolCall of callsToExecute) {
|
||||
if (toolCall.status !== 'scheduled') continue;
|
||||
|
||||
const scheduledCall = toolCall;
|
||||
const { callId, name: toolName } = scheduledCall.request;
|
||||
@@ -1033,107 +1032,106 @@ export class CoreToolScheduler {
|
||||
);
|
||||
}
|
||||
|
||||
promise
|
||||
.then(async (toolResult: ToolResult) => {
|
||||
if (signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const toolResult: ToolResult = await promise;
|
||||
if (signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolResult.error === undefined) {
|
||||
let content = toolResult.llmContent;
|
||||
let outputFile: string | undefined = undefined;
|
||||
const contentLength =
|
||||
typeof content === 'string' ? content.length : undefined;
|
||||
if (
|
||||
typeof content === 'string' &&
|
||||
toolName === ShellTool.Name &&
|
||||
this.config.getEnableToolOutputTruncation() &&
|
||||
this.config.getTruncateToolOutputThreshold() > 0 &&
|
||||
this.config.getTruncateToolOutputLines() > 0
|
||||
) {
|
||||
const originalContentLength = content.length;
|
||||
const threshold = this.config.getTruncateToolOutputThreshold();
|
||||
const lines = this.config.getTruncateToolOutputLines();
|
||||
const truncatedResult = await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
threshold,
|
||||
lines,
|
||||
);
|
||||
content = truncatedResult.content;
|
||||
outputFile = truncatedResult.outputFile;
|
||||
|
||||
if (outputFile) {
|
||||
logToolOutputTruncated(
|
||||
this.config,
|
||||
new ToolOutputTruncatedEvent(
|
||||
scheduledCall.request.prompt_id,
|
||||
{
|
||||
toolName,
|
||||
originalContentLength,
|
||||
truncatedContentLength: content.length,
|
||||
threshold,
|
||||
lines,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
if (toolResult.error === undefined) {
|
||||
let content = toolResult.llmContent;
|
||||
let outputFile: string | undefined = undefined;
|
||||
const contentLength =
|
||||
typeof content === 'string' ? content.length : undefined;
|
||||
if (
|
||||
typeof content === 'string' &&
|
||||
toolName === ShellTool.Name &&
|
||||
this.config.getEnableToolOutputTruncation() &&
|
||||
this.config.getTruncateToolOutputThreshold() > 0 &&
|
||||
this.config.getTruncateToolOutputLines() > 0
|
||||
) {
|
||||
const originalContentLength = content.length;
|
||||
const threshold = this.config.getTruncateToolOutputThreshold();
|
||||
const lines = this.config.getTruncateToolOutputLines();
|
||||
const truncatedResult = await truncateAndSaveToFile(
|
||||
content,
|
||||
);
|
||||
const successResponse: ToolCallResponseInfo = {
|
||||
callId,
|
||||
responseParts: response,
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
outputFile,
|
||||
contentLength,
|
||||
};
|
||||
this.setStatusInternal(callId, 'success', successResponse);
|
||||
} else {
|
||||
// It is a failure
|
||||
const error = new Error(toolResult.error.message);
|
||||
const errorResponse = createErrorResponse(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
threshold,
|
||||
lines,
|
||||
);
|
||||
content = truncatedResult.content;
|
||||
outputFile = truncatedResult.outputFile;
|
||||
|
||||
if (outputFile) {
|
||||
logToolOutputTruncated(
|
||||
this.config,
|
||||
new ToolOutputTruncatedEvent(
|
||||
scheduledCall.request.prompt_id,
|
||||
{
|
||||
toolName,
|
||||
originalContentLength,
|
||||
truncatedContentLength: content.length,
|
||||
threshold,
|
||||
lines,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
content,
|
||||
);
|
||||
const successResponse: ToolCallResponseInfo = {
|
||||
callId,
|
||||
responseParts: response,
|
||||
resultDisplay: toolResult.returnDisplay,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
outputFile,
|
||||
contentLength,
|
||||
};
|
||||
this.setStatusInternal(callId, 'success', successResponse);
|
||||
} else {
|
||||
// It is a failure
|
||||
const error = new Error(toolResult.error.message);
|
||||
const errorResponse = createErrorResponse(
|
||||
scheduledCall.request,
|
||||
error,
|
||||
toolResult.error.type,
|
||||
);
|
||||
this.setStatusInternal(callId, 'error', errorResponse);
|
||||
}
|
||||
} catch (executionError: unknown) {
|
||||
if (signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
} else {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'error',
|
||||
createErrorResponse(
|
||||
scheduledCall.request,
|
||||
error,
|
||||
toolResult.error.type,
|
||||
);
|
||||
this.setStatusInternal(callId, 'error', errorResponse);
|
||||
}
|
||||
})
|
||||
.catch((executionError: Error) => {
|
||||
if (signal.aborted) {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'cancelled',
|
||||
'User cancelled tool execution.',
|
||||
);
|
||||
} else {
|
||||
this.setStatusInternal(
|
||||
callId,
|
||||
'error',
|
||||
createErrorResponse(
|
||||
scheduledCall.request,
|
||||
executionError instanceof Error
|
||||
? executionError
|
||||
: new Error(String(executionError)),
|
||||
ToolErrorType.UNHANDLED_EXCEPTION,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
executionError instanceof Error
|
||||
? executionError
|
||||
: new Error(String(executionError)),
|
||||
ToolErrorType.UNHANDLED_EXCEPTION,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
|
||||
import { AuthType } from './contentGenerator.js';
|
||||
import { type RetryOptions } from '../utils/retry.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { Kind } from '../tools/tools.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
@@ -1305,259 +1303,6 @@ describe('GeminiChat', () => {
|
||||
expect(turn4.parts[0].text).toBe('second response');
|
||||
});
|
||||
|
||||
describe('stopBeforeSecondMutator', () => {
|
||||
beforeEach(() => {
|
||||
// Common setup for these tests: mock the tool registry.
|
||||
const mockToolRegistry = {
|
||||
getTool: vi.fn((toolName: string) => {
|
||||
if (toolName === 'edit') {
|
||||
return { kind: Kind.Edit };
|
||||
}
|
||||
return { kind: Kind.Other };
|
||||
}),
|
||||
} as unknown as ToolRegistry;
|
||||
vi.mocked(mockConfig.getToolRegistry).mockReturnValue(mockToolRegistry);
|
||||
});
|
||||
|
||||
it('should stop streaming before a second mutator tool call', async () => {
|
||||
const responses = [
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: 'model', parts: [{ text: 'First part. ' }] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'edit', args: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'fetch', args: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// This chunk contains the second mutator and should be clipped.
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ functionCall: { name: 'edit', args: {} } },
|
||||
{ text: 'some trailing text' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// This chunk should never be reached.
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'This should not appear.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[];
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
(async function* () {
|
||||
for (const response of responses) {
|
||||
yield response;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test message' },
|
||||
'prompt-id-mutator-test',
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
// Consume the stream to trigger history recording.
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
expect(history.length).toBe(2);
|
||||
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn.role).toBe('model');
|
||||
expect(modelTurn?.parts?.length).toBe(3);
|
||||
expect(modelTurn?.parts![0]!.text).toBe('First part. ');
|
||||
expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit');
|
||||
expect(modelTurn.parts![2]!.functionCall?.name).toBe('fetch');
|
||||
});
|
||||
|
||||
it('should not stop streaming if only one mutator is present', async () => {
|
||||
const responses = [
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: 'model', parts: [{ text: 'Part 1. ' }] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'edit', args: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: 'Part 2.' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[];
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
(async function* () {
|
||||
for (const response of responses) {
|
||||
yield response;
|
||||
}
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test message' },
|
||||
'prompt-id-one-mutator',
|
||||
);
|
||||
for await (const _ of stream) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn?.parts?.length).toBe(3);
|
||||
expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit');
|
||||
expect(modelTurn.parts![2]!.text).toBe('Part 2.');
|
||||
});
|
||||
|
||||
it('should clip the chunk containing the second mutator, preserving prior parts', async () => {
|
||||
const responses = [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'edit', args: {} } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// This chunk has a valid part before the second mutator.
|
||||
// The valid part should be kept, the rest of the chunk discarded.
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Keep this text. ' },
|
||||
{ functionCall: { name: 'edit', args: {} } },
|
||||
{ text: 'Discard this text.' },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[];
|
||||
|
||||
const stream = (async function* () {
|
||||
for (const response of responses) {
|
||||
yield response;
|
||||
}
|
||||
})();
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test' },
|
||||
'prompt-id-clip-chunk',
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn?.parts?.length).toBe(2);
|
||||
expect(modelTurn.parts![0]!.functionCall?.name).toBe('edit');
|
||||
expect(modelTurn.parts![1]!.text).toBe('Keep this text. ');
|
||||
});
|
||||
|
||||
it('should handle two mutators in the same chunk (parallel call scenario)', async () => {
|
||||
const responses = [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Some text. ' },
|
||||
{ functionCall: { name: 'edit', args: {} } },
|
||||
{ functionCall: { name: 'edit', args: {} } },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as GenerateContentResponse[];
|
||||
|
||||
const stream = (async function* () {
|
||||
for (const response of responses) {
|
||||
yield response;
|
||||
}
|
||||
})();
|
||||
|
||||
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||
stream,
|
||||
);
|
||||
|
||||
const resultStream = await chat.sendMessageStream(
|
||||
'test-model',
|
||||
{ message: 'test' },
|
||||
'prompt-id-parallel-mutators',
|
||||
);
|
||||
for await (const _ of resultStream) {
|
||||
/* consume */
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
const modelTurn = history[1]!;
|
||||
expect(modelTurn?.parts?.length).toBe(2);
|
||||
expect(modelTurn.parts![0]!.text).toBe('Some text. ');
|
||||
expect(modelTurn.parts![1]!.functionCall?.name).toBe('edit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model Resolution', () => {
|
||||
const mockResponse = {
|
||||
candidates: [
|
||||
|
||||
@@ -7,16 +7,15 @@
|
||||
// DISCLAIMER: This is a copied version of https://github.com/googleapis/js-genai/blob/main/src/chats.ts with the intention of working around a key bug
|
||||
// where function responses are not treated as "valid" responses: https://b.corp.google.com/issues/420354090
|
||||
|
||||
import {
|
||||
import type {
|
||||
GenerateContentResponse,
|
||||
type Content,
|
||||
type GenerateContentConfig,
|
||||
type SendMessageParameters,
|
||||
type Part,
|
||||
type Tool,
|
||||
FinishReason,
|
||||
ApiError,
|
||||
Content,
|
||||
GenerateContentConfig,
|
||||
SendMessageParameters,
|
||||
Part,
|
||||
Tool,
|
||||
} from '@google/genai';
|
||||
import { ApiError } from '@google/genai';
|
||||
import { toParts } from '../code_assist/converter.js';
|
||||
import { createUserContent } from '@google/genai';
|
||||
import { retryWithBackoff } from '../utils/retry.js';
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
getEffectiveModel,
|
||||
} from '../config/models.js';
|
||||
import { hasCycleInSchema, MUTATOR_KINDS } from '../tools/tools.js';
|
||||
import { hasCycleInSchema } from '../tools/tools.js';
|
||||
import type { StructuredError } from './turn.js';
|
||||
import {
|
||||
logContentRetry,
|
||||
@@ -511,7 +510,7 @@ export class GeminiChat {
|
||||
let hasToolCall = false;
|
||||
let hasFinishReason = false;
|
||||
|
||||
for await (const chunk of this.stopBeforeSecondMutator(streamResponse)) {
|
||||
for await (const chunk of streamResponse) {
|
||||
hasFinishReason =
|
||||
chunk?.candidates?.some((candidate) => candidate.finishReason) ?? false;
|
||||
if (isValidResponse(chunk)) {
|
||||
@@ -629,64 +628,6 @@ export class GeminiChat {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates the chunkStream right before the second function call to a
|
||||
* function that mutates state. This may involve trimming parts from a chunk
|
||||
* as well as omtting some chunks altogether.
|
||||
*
|
||||
* We do this because it improves tool call quality if the model gets
|
||||
* feedback from one mutating function call before it makes the next one.
|
||||
*/
|
||||
private async *stopBeforeSecondMutator(
|
||||
chunkStream: AsyncGenerator<GenerateContentResponse>,
|
||||
): AsyncGenerator<GenerateContentResponse> {
|
||||
let foundMutatorFunctionCall = false;
|
||||
|
||||
for await (const chunk of chunkStream) {
|
||||
const candidate = chunk.candidates?.[0];
|
||||
const content = candidate?.content;
|
||||
if (!candidate || !content?.parts) {
|
||||
yield chunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
const truncatedParts: Part[] = [];
|
||||
for (const part of content.parts) {
|
||||
if (this.isMutatorFunctionCall(part)) {
|
||||
if (foundMutatorFunctionCall) {
|
||||
// This is the second mutator call.
|
||||
// Truncate and return immedaitely.
|
||||
const newChunk = new GenerateContentResponse();
|
||||
newChunk.candidates = [
|
||||
{
|
||||
...candidate,
|
||||
content: {
|
||||
...content,
|
||||
parts: truncatedParts,
|
||||
},
|
||||
finishReason: FinishReason.STOP,
|
||||
},
|
||||
];
|
||||
yield newChunk;
|
||||
return;
|
||||
}
|
||||
foundMutatorFunctionCall = true;
|
||||
}
|
||||
truncatedParts.push(part);
|
||||
}
|
||||
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private isMutatorFunctionCall(part: Part): boolean {
|
||||
if (!part?.functionCall?.name) {
|
||||
return false;
|
||||
}
|
||||
const tool = this.config.getToolRegistry().getTool(part.functionCall.name);
|
||||
return !!tool && MUTATOR_KINDS.includes(tool.kind);
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible for Testing */
|
||||
|
||||
@@ -60,7 +60,10 @@ function verifyVSCode(
|
||||
if (ide.name !== IDE_DEFINITIONS.vscode.name) {
|
||||
return ide;
|
||||
}
|
||||
if (ideProcessInfo.command.toLowerCase().includes('code')) {
|
||||
if (
|
||||
ideProcessInfo.command &&
|
||||
ideProcessInfo.command.toLowerCase().includes('code')
|
||||
) {
|
||||
return IDE_DEFINITIONS.vscode;
|
||||
}
|
||||
return IDE_DEFINITIONS.vscodefork;
|
||||
|
||||
@@ -181,6 +181,56 @@ describe('ChatCompressionService', () => {
|
||||
expect(result.newHistory).toBeNull();
|
||||
});
|
||||
|
||||
it('should return NOOP when contextPercentageThreshold is 0', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
|
||||
vi.mocked(mockConfig.getChatCompression).mockReturnValue({
|
||||
contextPercentageThreshold: 0,
|
||||
});
|
||||
|
||||
const mockGenerateContent = vi.fn();
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.info).toMatchObject({
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
});
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
expect(tokenLimit).not.toHaveBeenCalled();
|
||||
|
||||
const forcedResult = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
true,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
expect(forcedResult.info).toMatchObject({
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
});
|
||||
expect(mockGenerateContent).not.toHaveBeenCalled();
|
||||
expect(tokenLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compress if over token threshold', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
|
||||
@@ -86,10 +86,14 @@ export class ChatCompressionService {
|
||||
hasFailedCompressionAttempt: boolean,
|
||||
): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> {
|
||||
const curatedHistory = chat.getHistory(true);
|
||||
const threshold =
|
||||
config.getChatCompression()?.contextPercentageThreshold ??
|
||||
COMPRESSION_TOKEN_THRESHOLD;
|
||||
|
||||
// Regardless of `force`, don't do anything if the history is empty.
|
||||
if (
|
||||
curatedHistory.length === 0 ||
|
||||
threshold <= 0 ||
|
||||
(hasFailedCompressionAttempt && !force)
|
||||
) {
|
||||
return {
|
||||
@@ -104,13 +108,8 @@ export class ChatCompressionService {
|
||||
|
||||
const originalTokenCount = uiTelemetryService.getLastPromptTokenCount();
|
||||
|
||||
const contextPercentageThreshold =
|
||||
config.getChatCompression()?.contextPercentageThreshold;
|
||||
|
||||
// Don't compress if not forced and we are under the limit.
|
||||
if (!force) {
|
||||
const threshold =
|
||||
contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD;
|
||||
if (originalTokenCount < threshold * tokenLimit(model)) {
|
||||
return {
|
||||
newHistory: null,
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('GlobTool', () => {
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -88,17 +89,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 +98,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 +185,7 @@ describe('GlobTool', () => {
|
||||
const filesListed = llmContent
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.slice(1)
|
||||
.slice(2)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -220,14 +198,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 +232,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 +265,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 +277,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 +287,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 +306,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 +365,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,36 @@ class GlobToolInvocation extends BaseToolInvocation<
|
||||
oneDayInMs,
|
||||
);
|
||||
|
||||
const sortedAbsolutePaths = sortedEntries.map((entry) =>
|
||||
const totalFileCount = sortedEntries.length;
|
||||
const fileLimit = Math.min(
|
||||
MAX_FILE_COUNT,
|
||||
this.config.getTruncateToolOutputLines(),
|
||||
);
|
||||
const truncated = totalFileCount > fileLimit;
|
||||
|
||||
// Limit to fileLimit if needed
|
||||
const entriesToShow = truncated
|
||||
? sortedEntries.slice(0, fileLimit)
|
||||
: 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 - fileLimit;
|
||||
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 +198,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 +206,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 +230,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 +256,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 +264,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ describe('GrepTool', () => {
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 25000,
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -84,11 +86,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 +113,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 +157,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 +171,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 +179,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 +246,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 () => {
|
||||
@@ -318,6 +284,8 @@ describe('GrepTool', () => {
|
||||
getFileExclusions: () => ({
|
||||
getGlobExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 25000,
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
const multiDirGrepTool = new GrepTool(multiDirConfig);
|
||||
@@ -346,16 +314,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 +336,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 +380,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,7 +12,7 @@ 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';
|
||||
@@ -37,14 +36,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 +69,60 @@ 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` };
|
||||
}
|
||||
|
||||
const charLimit = this.config.getTruncateToolOutputThreshold();
|
||||
const lineLimit = Math.min(
|
||||
this.config.getTruncateToolOutputLines(),
|
||||
this.params.limit ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
|
||||
// Apply line limit if specified
|
||||
let truncatedByLineLimit = false;
|
||||
let matchesToInclude = rawMatches;
|
||||
if (rawMatches.length > lineLimit) {
|
||||
matchesToInclude = rawMatches.slice(0, lineLimit);
|
||||
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 +135,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 (Number.isFinite(charLimit) && grepOutput.length > charLimit) {
|
||||
grepOutput = grepOutput.slice(0, charLimit) + '...';
|
||||
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 +272,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 +309,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
'--ignore-case',
|
||||
pattern,
|
||||
];
|
||||
if (include) {
|
||||
gitArgs.push('--', include);
|
||||
if (glob) {
|
||||
gitArgs.push('--', glob);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -457,8 +376,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 +456,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 +522,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 +554,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 +562,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);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ describe('ReadFileTool', () => {
|
||||
storage: {
|
||||
getProjectTempDir: () => path.join(tempRootDir, '.temp'),
|
||||
},
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as unknown as Config;
|
||||
tool = new ReadFileTool(mockConfigInstance);
|
||||
});
|
||||
@@ -281,11 +283,9 @@ describe('ReadFileTool', () => {
|
||||
>;
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain(
|
||||
'IMPORTANT: The file content has been truncated',
|
||||
expect(result.returnDisplay).toContain(
|
||||
'Read lines 1-2 of 3 from longlines.txt (truncated)',
|
||||
);
|
||||
expect(result.llmContent).toContain('--- FILE CONTENT (truncated) ---');
|
||||
expect(result.returnDisplay).toContain('some lines were shortened');
|
||||
});
|
||||
|
||||
it('should handle image file and return appropriate content', async () => {
|
||||
@@ -417,10 +417,7 @@ describe('ReadFileTool', () => {
|
||||
|
||||
const result = await invocation.execute(abortSignal);
|
||||
expect(result.llmContent).toContain(
|
||||
'IMPORTANT: The file content has been truncated',
|
||||
);
|
||||
expect(result.llmContent).toContain(
|
||||
'Status: Showing lines 6-8 of 20 total lines',
|
||||
'Showing lines 6-8 of 20 total lines',
|
||||
);
|
||||
expect(result.llmContent).toContain('Line 6');
|
||||
expect(result.llmContent).toContain('Line 7');
|
||||
|
||||
@@ -67,8 +67,7 @@ class ReadFileToolInvocation extends BaseToolInvocation<
|
||||
async execute(): Promise<ToolResult> {
|
||||
const result = await processSingleFileContent(
|
||||
this.params.absolute_path,
|
||||
this.config.getTargetDir(),
|
||||
this.config.getFileSystemService(),
|
||||
this.config,
|
||||
this.params.offset,
|
||||
this.params.limit,
|
||||
);
|
||||
@@ -88,16 +87,7 @@ class ReadFileToolInvocation extends BaseToolInvocation<
|
||||
if (result.isTruncated) {
|
||||
const [start, end] = result.linesShown!;
|
||||
const total = result.originalLineCount!;
|
||||
const nextOffset = this.params.offset
|
||||
? this.params.offset + end - start + 1
|
||||
: end;
|
||||
llmContent = `
|
||||
IMPORTANT: The file content has been truncated.
|
||||
Status: Showing lines ${start}-${end} of ${total} total lines.
|
||||
Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}.
|
||||
|
||||
--- FILE CONTENT (truncated) ---
|
||||
${result.llmContent}`;
|
||||
llmContent = `Showing lines ${start}-${end} of ${total} total lines.\n\n---\n\n${result.llmContent}`;
|
||||
} else {
|
||||
llmContent = result.llmContent || '';
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ describe('ReadManyFilesTool', () => {
|
||||
buildExcludePatterns: () => DEFAULT_FILE_EXCLUDES,
|
||||
getReadManyFilesExcludes: () => DEFAULT_FILE_EXCLUDES,
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as Partial<Config> as Config;
|
||||
tool = new ReadManyFilesTool(mockConfig);
|
||||
|
||||
@@ -500,6 +502,8 @@ describe('ReadManyFilesTool', () => {
|
||||
buildExcludePatterns: () => [],
|
||||
getReadManyFilesExcludes: () => [],
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as Partial<Config> as Config;
|
||||
tool = new ReadManyFilesTool(mockConfig);
|
||||
|
||||
@@ -552,15 +556,10 @@ describe('ReadManyFilesTool', () => {
|
||||
c.includes('large-file.txt'),
|
||||
);
|
||||
|
||||
expect(normalFileContent).not.toContain(
|
||||
'[WARNING: This file was truncated.',
|
||||
);
|
||||
expect(normalFileContent).not.toContain('Showing lines');
|
||||
expect(truncatedFileContent).toContain(
|
||||
"[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]",
|
||||
'Showing lines 1-250 of 2500 total lines.',
|
||||
);
|
||||
// Check that the actual content is still there but truncated
|
||||
expect(truncatedFileContent).toContain('L200');
|
||||
expect(truncatedFileContent).not.toContain('L2400');
|
||||
});
|
||||
|
||||
it('should read files with special characters like [] and () in the path', async () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
processSingleFileContent,
|
||||
DEFAULT_ENCODING,
|
||||
getSpecificMimeType,
|
||||
DEFAULT_MAX_LINES_TEXT_FILE,
|
||||
} from '../utils/fileUtils.js';
|
||||
import type { PartListUnion } from '@google/genai';
|
||||
import {
|
||||
@@ -278,8 +277,10 @@ ${finalExclusionPatternsForDescription
|
||||
}
|
||||
|
||||
const sortedFiles = Array.from(filesToConsider).sort();
|
||||
const file_line_limit =
|
||||
DEFAULT_MAX_LINES_TEXT_FILE / Math.max(1, sortedFiles.length);
|
||||
const truncateToolOutputLines = this.config.getTruncateToolOutputLines();
|
||||
const file_line_limit = Number.isFinite(truncateToolOutputLines)
|
||||
? Math.floor(truncateToolOutputLines / Math.max(1, sortedFiles.length))
|
||||
: undefined;
|
||||
|
||||
const fileProcessingPromises = sortedFiles.map(
|
||||
async (filePath): Promise<FileProcessingResult> => {
|
||||
@@ -316,8 +317,7 @@ ${finalExclusionPatternsForDescription
|
||||
// Use processSingleFileContent for all file types now
|
||||
const fileReadResult = await processSingleFileContent(
|
||||
filePath,
|
||||
this.config.getTargetDir(),
|
||||
this.config.getFileSystemService(),
|
||||
this.config,
|
||||
0,
|
||||
file_line_limit,
|
||||
);
|
||||
@@ -376,9 +376,12 @@ ${finalExclusionPatternsForDescription
|
||||
);
|
||||
let fileContentForLlm = '';
|
||||
if (fileReadResult.isTruncated) {
|
||||
fileContentForLlm += `[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]\n\n`;
|
||||
const [start, end] = fileReadResult.linesShown!;
|
||||
const total = fileReadResult.originalLineCount!;
|
||||
fileContentForLlm = `Showing lines ${start}-${end} of ${total} total lines.\n---\n${fileReadResult.llmContent}`;
|
||||
} else {
|
||||
fileContentForLlm = fileReadResult.llmContent;
|
||||
}
|
||||
fileContentForLlm += fileReadResult.llmContent;
|
||||
contentParts.push(`${separator}\n\n${fileContentForLlm}\n\n`);
|
||||
} else {
|
||||
// This is a Part for image/pdf, which we don't add the separator to.
|
||||
|
||||
@@ -103,6 +103,8 @@ describe('RipGrepTool', () => {
|
||||
getWorkingDir: () => tempRootDir,
|
||||
getDebugMode: () => false,
|
||||
getUseBuiltinRipgrep: () => true,
|
||||
getTruncateToolOutputThreshold: () => 25000,
|
||||
getTruncateToolOutputLines: () => 1000,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -184,17 +186,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('RipGrepTool', () => {
|
||||
});
|
||||
|
||||
it('should truncate llm content when exceeding maximum length', async () => {
|
||||
const longMatch = 'fileA.txt:1:' + 'a'.repeat(25_000);
|
||||
const longMatch = 'fileA.txt:1:' + 'a'.repeat(30_000);
|
||||
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
@@ -432,7 +432,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(26_000);
|
||||
expect(result.llmContent).toMatch(/\[\d+ lines? truncated\] \.\.\./);
|
||||
expect(result.returnDisplay).toContain('truncated');
|
||||
});
|
||||
@@ -567,6 +567,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 +668,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 +1154,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 +1167,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 +1189,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,16 +11,14 @@ 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';
|
||||
import type { FileFilteringOptions } from '../config/constants.js';
|
||||
import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js';
|
||||
|
||||
const MAX_LLM_CONTENT_LENGTH = 20_000;
|
||||
|
||||
/**
|
||||
* Parameters for the GrepTool (Simplified)
|
||||
*/
|
||||
@@ -57,50 +55,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,34 +94,50 @@ 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;
|
||||
|
||||
const charLimit = this.config.getTruncateToolOutputThreshold();
|
||||
const lineLimit = Math.min(
|
||||
this.config.getTruncateToolOutputLines(),
|
||||
this.params.limit ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
|
||||
// Apply line limit first (if specified)
|
||||
let truncatedByLineLimit = false;
|
||||
let linesToInclude = allLines;
|
||||
if (
|
||||
this.params.limit !== undefined &&
|
||||
allLines.length > this.params.limit
|
||||
) {
|
||||
linesToInclude = allLines.slice(0, this.params.limit);
|
||||
if (allLines.length > lineLimit) {
|
||||
linesToInclude = allLines.slice(0, lineLimit);
|
||||
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
|
||||
let grepOutput = '';
|
||||
let truncatedByCharLimit = false;
|
||||
if (grepOutput.length > maxGrepOutputLength) {
|
||||
grepOutput = grepOutput.slice(0, maxGrepOutputLength) + '...';
|
||||
truncatedByCharLimit = true;
|
||||
}
|
||||
let includedLines = 0;
|
||||
if (Number.isFinite(charLimit)) {
|
||||
const parts: string[] = [];
|
||||
let currentLength = 0;
|
||||
|
||||
// Count how many lines we actually included after character truncation
|
||||
const finalLines = grepOutput.split(EOL).filter((line) => line.trim());
|
||||
const includedLines = finalLines.length;
|
||||
for (const line of linesToInclude) {
|
||||
const sep = includedLines > 0 ? 1 : 0;
|
||||
includedLines++;
|
||||
|
||||
const projectedLength = currentLength + line.length + sep;
|
||||
if (projectedLength <= charLimit) {
|
||||
parts.push(line);
|
||||
currentLength = projectedLength;
|
||||
} else {
|
||||
const remaining = Math.max(charLimit - currentLength - sep, 10);
|
||||
parts.push(line.slice(0, remaining) + '...');
|
||||
truncatedByCharLimit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
grepOutput = parts.join('\n');
|
||||
} else {
|
||||
grepOutput = linesToInclude.join('\n');
|
||||
includedLines = linesToInclude.length;
|
||||
}
|
||||
|
||||
// Build result
|
||||
let llmContent = header + grepOutput;
|
||||
@@ -168,7 +145,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 +170,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 +279,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 +334,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 +360,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);
|
||||
}
|
||||
|
||||
@@ -21,4 +21,6 @@ export const ToolNames = {
|
||||
MEMORY: 'save_memory',
|
||||
TASK: 'task',
|
||||
EXIT_PLAN_MODE: 'exit_plan_mode',
|
||||
WEB_FETCH: 'web_fetch',
|
||||
WEB_SEARCH: 'web_search',
|
||||
} as const;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ToolConfirmationOutcome,
|
||||
} from './tools.js';
|
||||
import { DEFAULT_QWEN_MODEL } from '../config/models.js';
|
||||
import { ToolNames } from './tool-names.js';
|
||||
|
||||
const URL_FETCH_TIMEOUT_MS = 10000;
|
||||
const MAX_CONTENT_LENGTH = 100000;
|
||||
@@ -190,7 +191,7 @@ export class WebFetchTool extends BaseDeclarativeTool<
|
||||
WebFetchToolParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name: string = 'web_fetch';
|
||||
static readonly Name: string = ToolNames.WEB_FETCH;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
WebSearchProviderConfig,
|
||||
DashScopeProviderConfig,
|
||||
} from './types.js';
|
||||
import { ToolNames } from '../tool-names.js';
|
||||
|
||||
class WebSearchToolInvocation extends BaseToolInvocation<
|
||||
WebSearchToolParams,
|
||||
@@ -274,7 +275,7 @@ export class WebSearchTool extends BaseDeclarativeTool<
|
||||
WebSearchToolParams,
|
||||
WebSearchToolResult
|
||||
> {
|
||||
static readonly Name: string = 'web_search';
|
||||
static readonly Name: string = ToolNames.WEB_SEARCH;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
super(
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import type { Content } from '@google/genai';
|
||||
import {
|
||||
getEnvironmentContext,
|
||||
getDirectoryContextString,
|
||||
getInitialChatHistory,
|
||||
} from './environmentContext.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getFolderStructure } from './getFolderStructure.js';
|
||||
@@ -213,3 +215,102 @@ describe('getEnvironmentContext', () => {
|
||||
expect(parts[1].text).toBe('\n--- Error reading full file context ---');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitialChatHistory', () => {
|
||||
let mockConfig: Partial<Config>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getFolderStructure).mockResolvedValue('Mock Folder Structure');
|
||||
mockConfig = {
|
||||
getSkipStartupContext: vi.fn().mockReturnValue(false),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
getDirectories: vi.fn().mockReturnValue(['/test/dir']),
|
||||
}),
|
||||
getFileService: vi.fn(),
|
||||
getFullContext: vi.fn().mockReturnValue(false),
|
||||
getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('includes startup context when skipStartupContext is false', async () => {
|
||||
const history = await getInitialChatHistory(mockConfig as Config);
|
||||
|
||||
expect(mockConfig.getSkipStartupContext).toHaveBeenCalled();
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history).toEqual([
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
parts: [
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining(
|
||||
"I'm currently working in the directory",
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the context!' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only extra history when skipStartupContext is true', async () => {
|
||||
mockConfig.getSkipStartupContext = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getWorkspaceContext = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getWorkspaceContext should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
mockConfig.getFullContext = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getFullContext should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
mockConfig.getToolRegistry = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getToolRegistry should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
const extraHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'custom context' }] },
|
||||
];
|
||||
|
||||
const history = await getInitialChatHistory(
|
||||
mockConfig as Config,
|
||||
extraHistory,
|
||||
);
|
||||
|
||||
expect(mockConfig.getSkipStartupContext).toHaveBeenCalled();
|
||||
expect(history).toEqual(extraHistory);
|
||||
expect(history).not.toBe(extraHistory);
|
||||
});
|
||||
|
||||
it('returns empty history when skipping startup context without extras', async () => {
|
||||
mockConfig.getSkipStartupContext = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getWorkspaceContext = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getWorkspaceContext should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
mockConfig.getFullContext = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getFullContext should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
mockConfig.getToolRegistry = vi.fn(() => {
|
||||
throw new Error(
|
||||
'getToolRegistry should not be called when skipping startup context',
|
||||
);
|
||||
});
|
||||
|
||||
const history = await getInitialChatHistory(mockConfig as Config);
|
||||
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,10 @@ export async function getInitialChatHistory(
|
||||
config: Config,
|
||||
extraHistory?: Content[],
|
||||
): Promise<Content[]> {
|
||||
if (config.getSkipStartupContext()) {
|
||||
return extraHistory ? [...extraHistory] : [];
|
||||
}
|
||||
|
||||
const envParts = await getEnvironmentContext(config);
|
||||
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
readFileWithEncoding,
|
||||
fileExists,
|
||||
} from './fileUtils.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
vi.mock('mime/lite', () => ({
|
||||
default: { getType: vi.fn() },
|
||||
@@ -50,6 +50,12 @@ describe('fileUtils', () => {
|
||||
let nonexistentFilePath: string;
|
||||
let directoryPath: string;
|
||||
|
||||
const mockConfig = {
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
getTargetDir: () => tempRootDir,
|
||||
} as unknown as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); // Reset all mocks, including mime.getType
|
||||
|
||||
@@ -664,8 +670,7 @@ describe('fileUtils', () => {
|
||||
actualNodeFs.writeFileSync(testTextFilePath, content);
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.llmContent).toBe(content);
|
||||
expect(result.returnDisplay).toBe('');
|
||||
@@ -675,8 +680,7 @@ describe('fileUtils', () => {
|
||||
it('should handle file not found', async () => {
|
||||
const result = await processSingleFileContent(
|
||||
nonexistentFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.error).toContain('File not found');
|
||||
expect(result.returnDisplay).toContain('File not found');
|
||||
@@ -689,8 +693,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.error).toContain('Simulated read error');
|
||||
expect(result.returnDisplay).toContain('Simulated read error');
|
||||
@@ -704,8 +707,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testImageFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.error).toContain('Simulated image read error');
|
||||
expect(result.returnDisplay).toContain('Simulated image read error');
|
||||
@@ -717,8 +719,7 @@ describe('fileUtils', () => {
|
||||
mockMimeGetType.mockReturnValue('image/png');
|
||||
const result = await processSingleFileContent(
|
||||
testImageFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(
|
||||
(result.llmContent as { inlineData: unknown }).inlineData,
|
||||
@@ -739,8 +740,7 @@ describe('fileUtils', () => {
|
||||
mockMimeGetType.mockReturnValue('application/pdf');
|
||||
const result = await processSingleFileContent(
|
||||
testPdfFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(
|
||||
(result.llmContent as { inlineData: unknown }).inlineData,
|
||||
@@ -768,8 +768,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testSvgFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(result.llmContent).toBe(svgContent);
|
||||
@@ -786,8 +785,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testBinaryFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
expect(result.llmContent).toContain(
|
||||
'Cannot display content of binary file',
|
||||
@@ -796,11 +794,7 @@ describe('fileUtils', () => {
|
||||
});
|
||||
|
||||
it('should handle path being a directory', async () => {
|
||||
const result = await processSingleFileContent(
|
||||
directoryPath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
);
|
||||
const result = await processSingleFileContent(directoryPath, mockConfig);
|
||||
expect(result.error).toContain('Path is a directory');
|
||||
expect(result.returnDisplay).toContain('Path is a directory');
|
||||
});
|
||||
@@ -811,8 +805,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
5,
|
||||
5,
|
||||
); // Read lines 6-10
|
||||
@@ -832,8 +825,7 @@ describe('fileUtils', () => {
|
||||
// Read from line 11 to 20. The start is not 0, so it's truncated.
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
10,
|
||||
10,
|
||||
);
|
||||
@@ -852,8 +844,7 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
0,
|
||||
10,
|
||||
);
|
||||
@@ -875,17 +866,16 @@ describe('fileUtils', () => {
|
||||
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(result.llmContent).toContain('Short line');
|
||||
expect(result.llmContent).toContain(
|
||||
longLine.substring(0, 2000) + '... [truncated]',
|
||||
);
|
||||
expect(result.llmContent).toContain('Another short line');
|
||||
expect(result.llmContent).not.toContain('Another short line');
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read all 3 lines from test.txt (some lines were shortened)',
|
||||
'Read lines 1-2 of 3 from test.txt (truncated)',
|
||||
);
|
||||
expect(result.isTruncated).toBe(true);
|
||||
});
|
||||
@@ -897,8 +887,7 @@ describe('fileUtils', () => {
|
||||
// Read 5 lines, but there are 11 total
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
0,
|
||||
5,
|
||||
);
|
||||
@@ -916,15 +905,14 @@ describe('fileUtils', () => {
|
||||
// Read all 11 lines, including the long one
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
0,
|
||||
11,
|
||||
);
|
||||
|
||||
expect(result.isTruncated).toBe(true);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read all 11 lines from test.txt (some lines were shortened)',
|
||||
'Read lines 1-11 of 11 from test.txt (truncated)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -942,14 +930,13 @@ describe('fileUtils', () => {
|
||||
// Read 10 lines out of 20, including the long line
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
0,
|
||||
10,
|
||||
);
|
||||
expect(result.isTruncated).toBe(true);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'Read lines 1-10 of 20 from test.txt (some lines were shortened)',
|
||||
'Read lines 1-5 of 20 from test.txt (truncated)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -966,8 +953,7 @@ describe('fileUtils', () => {
|
||||
try {
|
||||
const result = await processSingleFileContent(
|
||||
testTextFilePath,
|
||||
tempRootDir,
|
||||
new StandardFileSystemService(),
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(result.error).toContain('File size exceeds the 20MB limit');
|
||||
|
||||
@@ -9,13 +9,9 @@ import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { PartUnion } from '@google/genai';
|
||||
import mime from 'mime/lite';
|
||||
import type { FileSystemService } from '../services/fileSystemService.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { BINARY_EXTENSIONS } from './ignorePatterns.js';
|
||||
|
||||
// Constants for text file processing
|
||||
export const DEFAULT_MAX_LINES_TEXT_FILE = 2000;
|
||||
const MAX_LINE_LENGTH_TEXT_FILE = 2000;
|
||||
import type { Config } from '../config/config.js';
|
||||
|
||||
// Default values for encoding and separator format
|
||||
export const DEFAULT_ENCODING: BufferEncoding = 'utf-8';
|
||||
@@ -306,18 +302,18 @@ export interface ProcessedFileReadResult {
|
||||
/**
|
||||
* Reads and processes a single file, handling text, images, and PDFs.
|
||||
* @param filePath Absolute path to the file.
|
||||
* @param rootDirectory Absolute path to the project root for relative path display.
|
||||
* @param config Config instance for truncation settings.
|
||||
* @param offset Optional offset for text files (0-based line number).
|
||||
* @param limit Optional limit for text files (number of lines to read).
|
||||
* @returns ProcessedFileReadResult object.
|
||||
*/
|
||||
export async function processSingleFileContent(
|
||||
filePath: string,
|
||||
rootDirectory: string,
|
||||
fileSystemService: FileSystemService,
|
||||
config: Config,
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
): Promise<ProcessedFileReadResult> {
|
||||
const rootDirectory = config.getTargetDir();
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Sync check is acceptable before async read
|
||||
@@ -379,45 +375,76 @@ export async function processSingleFileContent(
|
||||
case 'text': {
|
||||
// Use BOM-aware reader to avoid leaving a BOM character in content and to support UTF-16/32 transparently
|
||||
const content = await readFileWithEncoding(filePath);
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split('\n').map((line) => line.trimEnd());
|
||||
const originalLineCount = lines.length;
|
||||
|
||||
const startLine = offset || 0;
|
||||
const effectiveLimit =
|
||||
limit === undefined ? DEFAULT_MAX_LINES_TEXT_FILE : limit;
|
||||
const configLineLimit = config.getTruncateToolOutputLines();
|
||||
const configCharLimit = config.getTruncateToolOutputThreshold();
|
||||
const effectiveLimit = limit === undefined ? configLineLimit : limit;
|
||||
|
||||
// Ensure endLine does not exceed originalLineCount
|
||||
const endLine = Math.min(startLine + effectiveLimit, originalLineCount);
|
||||
// Ensure selectedLines doesn't try to slice beyond array bounds if startLine is too high
|
||||
const actualStartLine = Math.min(startLine, originalLineCount);
|
||||
const selectedLines = lines.slice(actualStartLine, endLine);
|
||||
|
||||
let linesWereTruncatedInLength = false;
|
||||
const formattedLines = selectedLines.map((line) => {
|
||||
if (line.length > MAX_LINE_LENGTH_TEXT_FILE) {
|
||||
linesWereTruncatedInLength = true;
|
||||
return (
|
||||
line.substring(0, MAX_LINE_LENGTH_TEXT_FILE) + '... [truncated]'
|
||||
);
|
||||
// Apply character limit truncation
|
||||
let llmContent = '';
|
||||
let contentLengthTruncated = false;
|
||||
let linesIncluded = 0;
|
||||
|
||||
if (Number.isFinite(configCharLimit)) {
|
||||
const formattedLines: string[] = [];
|
||||
let currentLength = 0;
|
||||
|
||||
for (const line of selectedLines) {
|
||||
const sep = linesIncluded > 0 ? 1 : 0; // newline separator
|
||||
linesIncluded++;
|
||||
|
||||
const projectedLength = currentLength + line.length + sep;
|
||||
if (projectedLength <= configCharLimit) {
|
||||
formattedLines.push(line);
|
||||
currentLength = projectedLength;
|
||||
} else {
|
||||
// Truncate the current line to fit
|
||||
const remaining = Math.max(
|
||||
configCharLimit - currentLength - sep,
|
||||
10,
|
||||
);
|
||||
formattedLines.push(
|
||||
line.substring(0, remaining) + '... [truncated]',
|
||||
);
|
||||
contentLengthTruncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
llmContent = formattedLines.join('\n');
|
||||
} else {
|
||||
// No character limit, use all selected lines
|
||||
llmContent = selectedLines.join('\n');
|
||||
linesIncluded = selectedLines.length;
|
||||
}
|
||||
|
||||
// Calculate actual end line shown
|
||||
const actualEndLine = contentLengthTruncated
|
||||
? actualStartLine + linesIncluded
|
||||
: endLine;
|
||||
|
||||
const contentRangeTruncated =
|
||||
startLine > 0 || endLine < originalLineCount;
|
||||
const isTruncated = contentRangeTruncated || linesWereTruncatedInLength;
|
||||
const llmContent = formattedLines.join('\n');
|
||||
startLine > 0 || actualEndLine < originalLineCount;
|
||||
const isTruncated = contentRangeTruncated || contentLengthTruncated;
|
||||
|
||||
// By default, return nothing to streamline the common case of a successful read_file.
|
||||
let returnDisplay = '';
|
||||
if (contentRangeTruncated) {
|
||||
if (isTruncated) {
|
||||
returnDisplay = `Read lines ${
|
||||
actualStartLine + 1
|
||||
}-${endLine} of ${originalLineCount} from ${relativePathForDisplay}`;
|
||||
if (linesWereTruncatedInLength) {
|
||||
returnDisplay += ' (some lines were shortened)';
|
||||
}-${actualEndLine} of ${originalLineCount} from ${relativePathForDisplay}`;
|
||||
if (contentLengthTruncated) {
|
||||
returnDisplay += ' (truncated)';
|
||||
}
|
||||
} else if (linesWereTruncatedInLength) {
|
||||
returnDisplay = `Read all ${originalLineCount} lines from ${relativePathForDisplay} (some lines were shortened)`;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -425,7 +452,7 @@ export async function processSingleFileContent(
|
||||
returnDisplay,
|
||||
isTruncated,
|
||||
originalLineCount,
|
||||
linesShown: [actualStartLine + 1, endLine],
|
||||
linesShown: [actualStartLine + 1, actualEndLine],
|
||||
};
|
||||
}
|
||||
case 'image':
|
||||
|
||||
@@ -29,6 +29,8 @@ const createMockConfig = (
|
||||
getTargetDir: () => cwd,
|
||||
getFileSystemService: () => fileSystemService,
|
||||
getFileService: () => mockFileService,
|
||||
getTruncateToolOutputThreshold: () => 2500,
|
||||
getTruncateToolOutputLines: () => 500,
|
||||
} as unknown as Config;
|
||||
};
|
||||
|
||||
|
||||
@@ -83,11 +83,7 @@ export async function readPathFromWorkspace(
|
||||
for (const filePath of finalFiles) {
|
||||
const relativePathForDisplay = path.relative(absolutePath, filePath);
|
||||
allParts.push({ text: `--- ${relativePathForDisplay} ---\n` });
|
||||
const result = await processSingleFileContent(
|
||||
filePath,
|
||||
config.getTargetDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const result = await processSingleFileContent(filePath, config);
|
||||
allParts.push(result.llmContent);
|
||||
allParts.push({ text: '\n' }); // Add a newline for separation
|
||||
}
|
||||
@@ -108,11 +104,7 @@ export async function readPathFromWorkspace(
|
||||
}
|
||||
|
||||
// It's a single file, process it directly.
|
||||
const result = await processSingleFileContent(
|
||||
absolutePath,
|
||||
config.getTargetDir(),
|
||||
config.getFileSystemService(),
|
||||
);
|
||||
const result = await processSingleFileContent(absolutePath, config);
|
||||
return [result.llmContent];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,69 +0,0 @@
|
||||
{
|
||||
"name": "@qwen-code/sdk-typescript",
|
||||
"version": "0.1.0",
|
||||
"description": "TypeScript SDK for programmatic access to qwen-code CLI",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src test",
|
||||
"lint:fix": "eslint src test --fix",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"qwen",
|
||||
"qwen-code",
|
||||
"ai",
|
||||
"code-assistant",
|
||||
"sdk",
|
||||
"typescript"
|
||||
],
|
||||
"author": "Qwen Team",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"@qwen-code/qwen-code": "file:../../cli"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.0",
|
||||
"@typescript-eslint/parser": "^7.13.0",
|
||||
"@vitest/coverage-v8": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/qwen-ai/qwen-code.git",
|
||||
"directory": "packages/sdk/typescript"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/qwen-ai/qwen-code/issues"
|
||||
},
|
||||
"homepage": "https://github.com/qwen-ai/qwen-code#readme"
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* TypeScript SDK for programmatic access to qwen-code CLI
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { query } from '@qwen-code/sdk-typescript';
|
||||
*
|
||||
* const q = query({
|
||||
* prompt: 'What files are in this directory?',
|
||||
* options: { cwd: process.cwd() },
|
||||
* });
|
||||
*
|
||||
* for await (const message of q) {
|
||||
* if (message.type === 'assistant') {
|
||||
* console.log(message.message.content);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* await q.close();
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Main API
|
||||
export { query } from './query/createQuery.js';
|
||||
|
||||
/** @deprecated Use query() instead */
|
||||
export { createQuery } from './query/createQuery.js';
|
||||
|
||||
export { Query } from './query/Query.js';
|
||||
|
||||
// Configuration types
|
||||
export type {
|
||||
CreateQueryOptions,
|
||||
PermissionMode,
|
||||
PermissionCallback,
|
||||
ExternalMcpServerConfig,
|
||||
TransportOptions,
|
||||
} from './types/config.js';
|
||||
|
||||
export type { QueryOptions } from './query/createQuery.js';
|
||||
|
||||
// Protocol types
|
||||
export type {
|
||||
ContentBlock,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolUseBlock,
|
||||
ToolResultBlock,
|
||||
CLIUserMessage,
|
||||
CLIAssistantMessage,
|
||||
CLISystemMessage,
|
||||
CLIResultMessage,
|
||||
CLIPartialAssistantMessage,
|
||||
CLIMessage,
|
||||
} from './types/protocol.js';
|
||||
|
||||
export {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
} from './types/protocol.js';
|
||||
|
||||
export type { JSONSchema } from './types/mcp.js';
|
||||
|
||||
export { AbortError, isAbortError } from './types/errors.js';
|
||||
|
||||
// Control Request Types
|
||||
export {
|
||||
ControlRequestType,
|
||||
getAllControlRequestTypes,
|
||||
isValidControlRequestType,
|
||||
} from './types/controlRequests.js';
|
||||
|
||||
// Transport
|
||||
export { ProcessTransport } from './transport/ProcessTransport.js';
|
||||
export type { Transport } from './transport/Transport.js';
|
||||
|
||||
// Utilities
|
||||
export { Stream } from './utils/Stream.js';
|
||||
export {
|
||||
serializeJsonLine,
|
||||
parseJsonLine,
|
||||
parseJsonLineSafe,
|
||||
isValidMessage,
|
||||
parseJsonLinesStream,
|
||||
} from './utils/jsonLines.js';
|
||||
export {
|
||||
findCliPath,
|
||||
resolveCliPath,
|
||||
prepareSpawnInfo,
|
||||
} from './utils/cliPath.js';
|
||||
export type { SpawnInfo } from './utils/cliPath.js';
|
||||
|
||||
// MCP helpers
|
||||
export {
|
||||
createSdkMcpServer,
|
||||
createSimpleMcpServer,
|
||||
} from './mcp/createSdkMcpServer.js';
|
||||
export {
|
||||
tool,
|
||||
createTool,
|
||||
validateToolName,
|
||||
validateInputSchema,
|
||||
} from './mcp/tool.js';
|
||||
|
||||
export type { ToolDefinition } from './types/config.js';
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* SdkControlServerTransport - bridges MCP Server with Query's control plane
|
||||
*
|
||||
* Implements @modelcontextprotocol/sdk Transport interface to enable
|
||||
* SDK-embedded MCP servers. Messages flow bidirectionally:
|
||||
*
|
||||
* MCP Server → send() → Query → control_request (mcp_message) → CLI
|
||||
* CLI → control_request (mcp_message) → Query → handleMessage() → MCP Server
|
||||
*/
|
||||
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* Callback type for sending messages to Query
|
||||
*/
|
||||
export type SendToQueryCallback = (message: JSONRPCMessage) => Promise<void>;
|
||||
|
||||
/**
|
||||
* SdkControlServerTransport options
|
||||
*/
|
||||
export interface SdkControlServerTransportOptions {
|
||||
sendToQuery: SendToQueryCallback;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport adapter that bridges MCP Server with Query's control plane
|
||||
*/
|
||||
export class SdkControlServerTransport {
|
||||
public sendToQuery: SendToQueryCallback;
|
||||
private serverName: string;
|
||||
private started = false;
|
||||
|
||||
/**
|
||||
* Callbacks set by MCP Server
|
||||
*/
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onclose?: () => void;
|
||||
|
||||
constructor(options: SdkControlServerTransportOptions) {
|
||||
this.sendToQuery = options.sendToQuery;
|
||||
this.serverName = options.serverName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the transport
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message from MCP Server to CLI via Query's control plane
|
||||
*
|
||||
* @param message - JSON-RPC message from MCP Server
|
||||
*/
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (!this.started) {
|
||||
throw new Error(
|
||||
`SdkControlServerTransport (${this.serverName}) not started. Call start() first.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Send via Query's control plane
|
||||
await this.sendToQuery(message);
|
||||
} catch (error) {
|
||||
// Invoke error callback if set
|
||||
if (this.onerror) {
|
||||
this.onerror(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the transport
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (!this.started) {
|
||||
return; // Already closed
|
||||
}
|
||||
|
||||
this.started = false;
|
||||
|
||||
// Notify MCP Server
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message from CLI
|
||||
*
|
||||
* @param message - JSON-RPC message from CLI
|
||||
*/
|
||||
handleMessage(message: JSONRPCMessage): void {
|
||||
if (!this.started) {
|
||||
console.warn(
|
||||
`[SdkControlServerTransport] Received message for closed transport (${this.serverName})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.onmessage) {
|
||||
this.onmessage(message);
|
||||
} else {
|
||||
console.warn(
|
||||
`[SdkControlServerTransport] No onmessage handler set for ${this.serverName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming error from CLI
|
||||
*
|
||||
* @param error - Error from CLI
|
||||
*/
|
||||
handleError(error: Error): void {
|
||||
if (this.onerror) {
|
||||
this.onerror(error);
|
||||
} else {
|
||||
console.error(
|
||||
`[SdkControlServerTransport] Error for ${this.serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transport is started
|
||||
*/
|
||||
isStarted(): boolean {
|
||||
return this.started;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server name
|
||||
*/
|
||||
getServerName(): string {
|
||||
return this.serverName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SdkControlServerTransport instance
|
||||
*/
|
||||
export function createSdkControlServerTransport(
|
||||
options: SdkControlServerTransportOptions,
|
||||
): SdkControlServerTransport {
|
||||
return new SdkControlServerTransport(options);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Factory function to create SDK-embedded MCP servers
|
||||
*
|
||||
* Creates MCP Server instances that run in the user's Node.js process
|
||||
* and are proxied to the CLI via the control plane.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
CallToolResult,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ToolDefinition } from '../types/config.js';
|
||||
import { formatToolResult, formatToolError } from './formatters.js';
|
||||
import { validateToolName } from './tool.js';
|
||||
|
||||
/**
|
||||
* Create an SDK-embedded MCP server with custom tools
|
||||
*
|
||||
* The server runs in your Node.js process and is proxied to the CLI.
|
||||
*
|
||||
* @param name - Server name (must be unique)
|
||||
* @param version - Server version
|
||||
* @param tools - Array of tool definitions
|
||||
* @returns MCP Server instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const server = createSdkMcpServer('database', '1.0.0', [
|
||||
* tool({
|
||||
* name: 'query_db',
|
||||
* description: 'Query the database',
|
||||
* inputSchema: {
|
||||
* type: 'object',
|
||||
* properties: { query: { type: 'string' } },
|
||||
* required: ['query']
|
||||
* },
|
||||
* handler: async (input) => db.query(input.query)
|
||||
* })
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
export function createSdkMcpServer(
|
||||
name: string,
|
||||
version: string,
|
||||
tools: ToolDefinition[],
|
||||
): Server {
|
||||
// Validate server name
|
||||
if (!name || typeof name !== 'string') {
|
||||
throw new Error('MCP server name must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!version || typeof version !== 'string') {
|
||||
throw new Error('MCP server version must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!Array.isArray(tools)) {
|
||||
throw new Error('Tools must be an array');
|
||||
}
|
||||
|
||||
// Validate tool names are unique
|
||||
const toolNames = new Set<string>();
|
||||
for (const tool of tools) {
|
||||
validateToolName(tool.name);
|
||||
|
||||
if (toolNames.has(tool.name)) {
|
||||
throw new Error(
|
||||
`Duplicate tool name '${tool.name}' in MCP server '${name}'`,
|
||||
);
|
||||
}
|
||||
toolNames.add(tool.name);
|
||||
}
|
||||
|
||||
// Create MCP Server instance
|
||||
const server = new Server(
|
||||
{
|
||||
name,
|
||||
version,
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Create tool map for fast lookup
|
||||
const toolMap = new Map<string, ToolDefinition>();
|
||||
for (const tool of tools) {
|
||||
toolMap.set(tool.name, tool);
|
||||
}
|
||||
|
||||
// Register list_tools handler
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Register call_tool handler
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name: toolName, arguments: toolArgs } = request.params;
|
||||
|
||||
// Find tool
|
||||
const tool = toolMap.get(toolName);
|
||||
if (!tool) {
|
||||
return formatToolError(
|
||||
new Error(`Tool '${toolName}' not found in server '${name}'`),
|
||||
) as CallToolResult;
|
||||
}
|
||||
|
||||
try {
|
||||
// Invoke tool handler
|
||||
const result = await tool.handler(toolArgs);
|
||||
|
||||
// Format result
|
||||
return formatToolResult(result) as CallToolResult;
|
||||
} catch (error) {
|
||||
// Handle tool execution error
|
||||
return formatToolError(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error(`Tool '${toolName}' failed: ${String(error)}`),
|
||||
) as CallToolResult;
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MCP server with inline tool definitions
|
||||
*
|
||||
* @param name - Server name
|
||||
* @param version - Server version
|
||||
* @param toolDefinitions - Object mapping tool names to definitions
|
||||
* @returns MCP Server instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const server = createSimpleMcpServer('utils', '1.0.0', {
|
||||
* greeting: {
|
||||
* description: 'Generate a greeting',
|
||||
* inputSchema: {
|
||||
* type: 'object',
|
||||
* properties: { name: { type: 'string' } },
|
||||
* required: ['name']
|
||||
* },
|
||||
* handler: async ({ name }) => `Hello, ${name}!`
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createSimpleMcpServer(
|
||||
name: string,
|
||||
version: string,
|
||||
toolDefinitions: Record<
|
||||
string,
|
||||
Omit<ToolDefinition, 'name'> & { name?: string }
|
||||
>,
|
||||
): Server {
|
||||
const tools: ToolDefinition[] = Object.entries(toolDefinitions).map(
|
||||
([toolName, def]) => ({
|
||||
name: def.name || toolName,
|
||||
description: def.description,
|
||||
inputSchema: def.inputSchema,
|
||||
handler: def.handler,
|
||||
}),
|
||||
);
|
||||
|
||||
return createSdkMcpServer(name, version, tools);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* Tool result formatting utilities for MCP responses
|
||||
*
|
||||
* Converts various output types to MCP content blocks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP content block types
|
||||
*/
|
||||
export type McpContentBlock =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; data: string; mimeType: string }
|
||||
| { type: 'resource'; uri: string; mimeType?: string; text?: string };
|
||||
|
||||
/**
|
||||
* Tool result structure
|
||||
*/
|
||||
export interface ToolResult {
|
||||
content: McpContentBlock[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for MCP response
|
||||
*
|
||||
* Converts any value to MCP content blocks (strings, objects, errors, etc.)
|
||||
*
|
||||
* @param result - Tool handler output or error
|
||||
* @returns Formatted tool result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* formatToolResult('Hello')
|
||||
* // → { content: [{ type: 'text', text: 'Hello' }] }
|
||||
*
|
||||
* formatToolResult({ temperature: 72 })
|
||||
* // → { content: [{ type: 'text', text: '{"temperature":72}' }] }
|
||||
* ```
|
||||
*/
|
||||
export function formatToolResult(result: unknown): ToolResult {
|
||||
// Handle Error objects
|
||||
if (result instanceof Error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.message || 'Unknown error',
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle null/undefined
|
||||
if (result === null || result === undefined) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle string
|
||||
if (typeof result === 'string') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle number
|
||||
if (typeof result === 'number') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: String(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle boolean
|
||||
if (typeof result === 'boolean') {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: String(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle object (including arrays)
|
||||
if (typeof result === 'object') {
|
||||
try {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch {
|
||||
// JSON.stringify failed
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: String(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: convert to string
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: String(result),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for MCP response
|
||||
*
|
||||
* @param error - Error object or string
|
||||
* @returns Tool result with error flag
|
||||
*/
|
||||
export function formatToolError(error: Error | string): ToolResult {
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format text content for MCP response
|
||||
*
|
||||
* @param text - Text content
|
||||
* @returns Tool result with text content
|
||||
*/
|
||||
export function formatTextResult(text: string): ToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format JSON content for MCP response
|
||||
*
|
||||
* @param data - Data to serialize as JSON
|
||||
* @returns Tool result with JSON text content
|
||||
*/
|
||||
export function formatJsonResult(data: unknown): ToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple tool results into a single result
|
||||
*
|
||||
* @param results - Array of tool results
|
||||
* @returns Merged tool result
|
||||
*/
|
||||
export function mergeToolResults(results: ToolResult[]): ToolResult {
|
||||
const mergedContent: McpContentBlock[] = [];
|
||||
let hasError = false;
|
||||
|
||||
for (const result of results) {
|
||||
mergedContent.push(...result.content);
|
||||
if (result.isError) {
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: mergedContent,
|
||||
isError: hasError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate MCP content block
|
||||
*
|
||||
* @param block - Content block to validate
|
||||
* @returns True if valid
|
||||
*/
|
||||
export function isValidContentBlock(block: unknown): block is McpContentBlock {
|
||||
if (!block || typeof block !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const blockObj = block as Record<string, unknown>;
|
||||
|
||||
if (!blockObj.type || typeof blockObj.type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (blockObj.type) {
|
||||
case 'text':
|
||||
return typeof blockObj.text === 'string';
|
||||
|
||||
case 'image':
|
||||
return (
|
||||
typeof blockObj.data === 'string' &&
|
||||
typeof blockObj.mimeType === 'string'
|
||||
);
|
||||
|
||||
case 'resource':
|
||||
return typeof blockObj.uri === 'string';
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* Tool definition helper for SDK-embedded MCP servers
|
||||
*
|
||||
* Provides type-safe tool definitions with generic input/output types.
|
||||
*/
|
||||
|
||||
import type { ToolDefinition } from '../types/config.js';
|
||||
|
||||
/**
|
||||
* Create a type-safe tool definition
|
||||
*
|
||||
* Validates the tool definition and provides type inference for input/output types.
|
||||
*
|
||||
* @param def - Tool definition with handler
|
||||
* @returns The same tool definition (for type safety)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const weatherTool = tool<{ location: string }, { temperature: number }>({
|
||||
* name: 'get_weather',
|
||||
* description: 'Get weather for a location',
|
||||
* inputSchema: {
|
||||
* type: 'object',
|
||||
* properties: {
|
||||
* location: { type: 'string' }
|
||||
* },
|
||||
* required: ['location']
|
||||
* },
|
||||
* handler: async (input) => {
|
||||
* return { temperature: await fetchWeather(input.location) };
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function tool<TInput = unknown, TOutput = unknown>(
|
||||
def: ToolDefinition<TInput, TOutput>,
|
||||
): ToolDefinition<TInput, TOutput> {
|
||||
// Validate tool definition
|
||||
if (!def.name || typeof def.name !== 'string') {
|
||||
throw new Error('Tool definition must have a name (string)');
|
||||
}
|
||||
|
||||
if (!def.description || typeof def.description !== 'string') {
|
||||
throw new Error(
|
||||
`Tool definition for '${def.name}' must have a description (string)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!def.inputSchema || typeof def.inputSchema !== 'object') {
|
||||
throw new Error(
|
||||
`Tool definition for '${def.name}' must have an inputSchema (object)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!def.handler || typeof def.handler !== 'function') {
|
||||
throw new Error(
|
||||
`Tool definition for '${def.name}' must have a handler (function)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Return definition (pass-through for type safety)
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tool name
|
||||
*
|
||||
* Tool names must:
|
||||
* - Start with a letter
|
||||
* - Contain only letters, numbers, and underscores
|
||||
* - Be between 1 and 64 characters
|
||||
*
|
||||
* @param name - Tool name to validate
|
||||
* @throws Error if name is invalid
|
||||
*/
|
||||
export function validateToolName(name: string): void {
|
||||
if (!name) {
|
||||
throw new Error('Tool name cannot be empty');
|
||||
}
|
||||
|
||||
if (name.length > 64) {
|
||||
throw new Error(
|
||||
`Tool name '${name}' is too long (max 64 characters): ${name.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) {
|
||||
throw new Error(
|
||||
`Tool name '${name}' is invalid. Must start with a letter and contain only letters, numbers, and underscores.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tool input schema (JSON Schema compliance)
|
||||
*
|
||||
* @param schema - Input schema to validate
|
||||
* @throws Error if schema is invalid
|
||||
*/
|
||||
export function validateInputSchema(schema: unknown): void {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
throw new Error('Input schema must be an object');
|
||||
}
|
||||
|
||||
const schemaObj = schema as Record<string, unknown>;
|
||||
|
||||
if (!schemaObj.type) {
|
||||
throw new Error('Input schema must have a type field');
|
||||
}
|
||||
|
||||
// For object schemas, validate properties
|
||||
if (schemaObj.type === 'object') {
|
||||
if (schemaObj.properties && typeof schemaObj.properties !== 'object') {
|
||||
throw new Error('Input schema properties must be an object');
|
||||
}
|
||||
|
||||
if (schemaObj.required && !Array.isArray(schemaObj.required)) {
|
||||
throw new Error('Input schema required must be an array');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tool definition with strict validation
|
||||
*
|
||||
* @param def - Tool definition
|
||||
* @returns Validated tool definition
|
||||
*/
|
||||
export function createTool<TInput = unknown, TOutput = unknown>(
|
||||
def: ToolDefinition<TInput, TOutput>,
|
||||
): ToolDefinition<TInput, TOutput> {
|
||||
// Validate via tool() function
|
||||
const validated = tool(def);
|
||||
|
||||
// Additional validation
|
||||
validateToolName(validated.name);
|
||||
validateInputSchema(validated.inputSchema);
|
||||
|
||||
return validated;
|
||||
}
|
||||
@@ -1,895 +0,0 @@
|
||||
/**
|
||||
* Query class - Main orchestrator for SDK
|
||||
*
|
||||
* Manages SDK workflow, routes messages, and handles lifecycle.
|
||||
* Implements AsyncIterator protocol for message consumption.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
CLIMessage,
|
||||
CLIUserMessage,
|
||||
CLIControlRequest,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
PermissionApproval,
|
||||
PermissionSuggestion,
|
||||
} from '../types/protocol.js';
|
||||
import {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
} from '../types/protocol.js';
|
||||
import type { Transport } from '../transport/Transport.js';
|
||||
import type { CreateQueryOptions } from '../types/config.js';
|
||||
import { Stream } from '../utils/Stream.js';
|
||||
import { serializeJsonLine } from '../utils/jsonLines.js';
|
||||
import { AbortError } from '../types/errors.js';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { SdkControlServerTransport } from '../mcp/SdkControlServerTransport.js';
|
||||
import { ControlRequestType } from '../types/controlRequests.js';
|
||||
|
||||
/**
|
||||
* Pending control request tracking
|
||||
*/
|
||||
interface PendingControlRequest {
|
||||
resolve: (response: Record<string, unknown> | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook configuration for SDK initialization
|
||||
*/
|
||||
interface HookRegistration {
|
||||
matcher: Record<string, unknown>;
|
||||
hookCallbackIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport with input stream control (e.g., ProcessTransport)
|
||||
*/
|
||||
interface TransportWithEndInput extends Transport {
|
||||
endInput(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query class
|
||||
*
|
||||
* Main entry point for SDK users. Orchestrates communication with CLI,
|
||||
* routes messages, handles control plane, and manages lifecycle.
|
||||
*/
|
||||
export class Query implements AsyncIterable<CLIMessage> {
|
||||
private transport: Transport;
|
||||
private options: CreateQueryOptions;
|
||||
private sessionId: string;
|
||||
private inputStream: Stream<CLIMessage>;
|
||||
private abortController: AbortController;
|
||||
private pendingControlRequests: Map<string, PendingControlRequest> =
|
||||
new Map();
|
||||
private sdkMcpTransports: Map<string, SdkControlServerTransport> = new Map();
|
||||
private initialized: Promise<void> | null = null;
|
||||
private closed = false;
|
||||
private messageRouterStarted = false;
|
||||
|
||||
// First result tracking for MCP servers
|
||||
private firstResultReceivedPromise?: Promise<void>;
|
||||
private firstResultReceivedResolve?: () => void;
|
||||
|
||||
// Hook callbacks tracking
|
||||
private hookCallbacks = new Map<
|
||||
string,
|
||||
(
|
||||
input: unknown,
|
||||
toolUseId: string | null,
|
||||
options: { signal: AbortSignal },
|
||||
) => Promise<unknown>
|
||||
>();
|
||||
private nextCallbackId = 0;
|
||||
|
||||
// Single-turn mode flag
|
||||
private readonly isSingleTurn: boolean;
|
||||
|
||||
constructor(transport: Transport, options: CreateQueryOptions) {
|
||||
this.transport = transport;
|
||||
this.options = options;
|
||||
this.sessionId = randomUUID();
|
||||
this.inputStream = new Stream<CLIMessage>();
|
||||
// Use provided abortController or create a new one
|
||||
this.abortController = options.abortController ?? new AbortController();
|
||||
this.isSingleTurn = options.singleTurn ?? false;
|
||||
|
||||
// Setup first result tracking
|
||||
this.firstResultReceivedPromise = new Promise((resolve) => {
|
||||
this.firstResultReceivedResolve = resolve;
|
||||
});
|
||||
|
||||
// Handle abort signal if controller is provided and already aborted or will be aborted
|
||||
if (this.abortController.signal.aborted) {
|
||||
// Already aborted - set error immediately
|
||||
this.inputStream.setError(new AbortError('Query aborted by user'));
|
||||
this.close().catch((err) => {
|
||||
console.error('[Query] Error during abort cleanup:', err);
|
||||
});
|
||||
} else {
|
||||
// Listen for abort events on the controller's signal
|
||||
this.abortController.signal.addEventListener('abort', () => {
|
||||
// Set abort error on the stream before closing
|
||||
this.inputStream.setError(new AbortError('Query aborted by user'));
|
||||
this.close().catch((err) => {
|
||||
console.error('[Query] Error during abort cleanup:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize immediately (no lazy initialization)
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the query
|
||||
*/
|
||||
private initialize(): void {
|
||||
// Initialize asynchronously but don't block constructor
|
||||
// Capture the promise immediately so other code can wait for initialization
|
||||
this.initialized = (async () => {
|
||||
try {
|
||||
// Start transport
|
||||
await this.transport.start();
|
||||
|
||||
// Setup SDK-embedded MCP servers
|
||||
await this.setupSdkMcpServers();
|
||||
|
||||
// Prepare hooks configuration
|
||||
let hooks: Record<string, HookRegistration[]> | undefined;
|
||||
if (this.options.hooks) {
|
||||
hooks = {};
|
||||
for (const [event, matchers] of Object.entries(this.options.hooks)) {
|
||||
if (matchers.length > 0) {
|
||||
hooks[event] = matchers.map((matcher) => {
|
||||
const callbackIds: string[] = [];
|
||||
for (const callback of matcher.hooks) {
|
||||
const callbackId = `hook_${this.nextCallbackId++}`;
|
||||
this.hookCallbacks.set(callbackId, callback);
|
||||
callbackIds.push(callbackId);
|
||||
}
|
||||
return {
|
||||
matcher: matcher.matcher,
|
||||
hookCallbackIds: callbackIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start message router in background
|
||||
this.startMessageRouter();
|
||||
|
||||
// Send initialize control request
|
||||
const sdkMcpServerNames = Array.from(this.sdkMcpTransports.keys());
|
||||
await this.sendControlRequest(ControlRequestType.INITIALIZE, {
|
||||
hooks: hooks ? Object.values(hooks).flat() : null,
|
||||
sdkMcpServers:
|
||||
sdkMcpServerNames.length > 0 ? sdkMcpServerNames : undefined,
|
||||
});
|
||||
|
||||
// Note: Single-turn prompts are sent directly via transport in createQuery.ts
|
||||
} catch (error) {
|
||||
console.error('[Query] Initialization error:', error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup SDK-embedded MCP servers
|
||||
*/
|
||||
private async setupSdkMcpServers(): Promise<void> {
|
||||
if (!this.options.sdkMcpServers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate no name conflicts with external MCP servers
|
||||
const externalNames = Object.keys(this.options.mcpServers ?? {});
|
||||
const sdkNames = Object.keys(this.options.sdkMcpServers);
|
||||
|
||||
const conflicts = sdkNames.filter((name) => externalNames.includes(name));
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(
|
||||
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Import SdkControlServerTransport (dynamic to avoid circular deps)
|
||||
const { SdkControlServerTransport } = await import(
|
||||
'../mcp/SdkControlServerTransport.js'
|
||||
);
|
||||
|
||||
// Create SdkControlServerTransport for each server
|
||||
for (const [name, server] of Object.entries(this.options.sdkMcpServers)) {
|
||||
// Create transport that sends MCP messages via control plane
|
||||
const transport = new SdkControlServerTransport({
|
||||
serverName: name,
|
||||
sendToQuery: async (message: JSONRPCMessage) => {
|
||||
// Send MCP message to CLI via control request
|
||||
await this.sendControlRequest(ControlRequestType.MCP_MESSAGE, {
|
||||
server_name: name,
|
||||
message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Start transport
|
||||
await transport.start();
|
||||
|
||||
// Connect server to transport
|
||||
await server.connect(transport);
|
||||
|
||||
// Store transport for cleanup
|
||||
this.sdkMcpTransports.set(name, transport);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start message router (background task)
|
||||
*/
|
||||
private startMessageRouter(): void {
|
||||
if (this.messageRouterStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.messageRouterStarted = true;
|
||||
|
||||
// Route messages from transport to input stream
|
||||
(async () => {
|
||||
try {
|
||||
for await (const message of this.transport.readMessages()) {
|
||||
await this.routeMessage(message);
|
||||
|
||||
// Stop if closed
|
||||
if (this.closed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Transport completed - check if aborted first
|
||||
if (this.abortController.signal.aborted) {
|
||||
this.inputStream.setError(new AbortError('Query aborted'));
|
||||
} else {
|
||||
this.inputStream.done();
|
||||
}
|
||||
} catch (error) {
|
||||
// Transport error - propagate to stream
|
||||
this.inputStream.setError(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error('[Query] Message router error:', err);
|
||||
this.inputStream.setError(
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Route incoming message
|
||||
*/
|
||||
private async routeMessage(message: unknown): Promise<void> {
|
||||
// Check control messages first
|
||||
if (isControlRequest(message)) {
|
||||
// CLI asking SDK for something (permission, MCP message, hook callback)
|
||||
await this.handleControlRequest(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isControlResponse(message)) {
|
||||
// Response to SDK's control request
|
||||
this.handleControlResponse(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isControlCancel(message)) {
|
||||
// Cancel pending control request
|
||||
this.handleControlCancelRequest(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check data messages
|
||||
if (isCLISystemMessage(message)) {
|
||||
// SystemMessage - contains session info (cwd, tools, model, etc.) that should be passed to user
|
||||
this.inputStream.enqueue(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
// Result message - trigger first result received
|
||||
if (this.firstResultReceivedResolve) {
|
||||
this.firstResultReceivedResolve();
|
||||
}
|
||||
// In single-turn mode, automatically close input after receiving result
|
||||
if (this.isSingleTurn && 'endInput' in this.transport) {
|
||||
(this.transport as TransportWithEndInput).endInput();
|
||||
}
|
||||
// Pass to user
|
||||
this.inputStream.enqueue(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isCLIAssistantMessage(message) ||
|
||||
isCLIUserMessage(message) ||
|
||||
isCLIPartialAssistantMessage(message)
|
||||
) {
|
||||
// Pass to user
|
||||
this.inputStream.enqueue(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown message - log and pass through
|
||||
if (process.env['DEBUG_SDK']) {
|
||||
console.warn('[Query] Unknown message type:', message);
|
||||
}
|
||||
this.inputStream.enqueue(message as CLIMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control request from CLI
|
||||
*/
|
||||
private async handleControlRequest(
|
||||
request: CLIControlRequest,
|
||||
): Promise<void> {
|
||||
const { request_id, request: payload } = request;
|
||||
|
||||
// Create abort controller for this request
|
||||
const requestAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
let response: Record<string, unknown> | null = null;
|
||||
|
||||
switch (payload.subtype) {
|
||||
case 'can_use_tool':
|
||||
response = (await this.handlePermissionRequest(
|
||||
payload.tool_name,
|
||||
payload.input as Record<string, unknown>,
|
||||
payload.permission_suggestions,
|
||||
requestAbortController.signal,
|
||||
)) as unknown as Record<string, unknown>;
|
||||
break;
|
||||
|
||||
case 'mcp_message':
|
||||
response = await this.handleMcpMessage(
|
||||
payload.server_name,
|
||||
payload.message as unknown as JSONRPCMessage,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'hook_callback':
|
||||
response = await this.handleHookCallback(
|
||||
payload.callback_id,
|
||||
payload.input,
|
||||
payload.tool_use_id,
|
||||
requestAbortController.signal,
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown control request subtype: ${payload.subtype}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Send success response
|
||||
await this.sendControlResponse(request_id, true, response);
|
||||
} catch (error) {
|
||||
// Send error response
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
await this.sendControlResponse(request_id, false, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle permission request (can_use_tool)
|
||||
*/
|
||||
private async handlePermissionRequest(
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
permissionSuggestions: PermissionSuggestion[] | null,
|
||||
signal: AbortSignal,
|
||||
): Promise<PermissionApproval> {
|
||||
// Default: allow if no callback provided
|
||||
if (!this.options.canUseTool) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
try {
|
||||
// Invoke callback with timeout
|
||||
const timeoutMs = 30000; // 30 seconds
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error('Permission callback timeout')),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
// Call with signal and suggestions
|
||||
const result = await Promise.race([
|
||||
Promise.resolve(
|
||||
this.options.canUseTool(toolName, toolInput, {
|
||||
signal,
|
||||
suggestions: permissionSuggestions,
|
||||
}),
|
||||
),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
// Support both boolean and object return values
|
||||
if (typeof result === 'boolean') {
|
||||
return { allowed: result };
|
||||
}
|
||||
// Ensure result is a valid PermissionApproval
|
||||
return result as PermissionApproval;
|
||||
} catch (error) {
|
||||
// Timeout or error → deny (fail-safe)
|
||||
console.warn(
|
||||
'[Query] Permission callback error (denying by default):',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return { allowed: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MCP message routing
|
||||
*/
|
||||
private async handleMcpMessage(
|
||||
serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
): Promise<Record<string, unknown>> {
|
||||
// Get transport for this server
|
||||
const transport = this.sdkMcpTransports.get(serverName);
|
||||
if (!transport) {
|
||||
throw new Error(
|
||||
`MCP server '${serverName}' not found in SDK-embedded servers`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a request (has method and id) or notification
|
||||
const isRequest =
|
||||
'method' in message && 'id' in message && message.id !== null;
|
||||
|
||||
if (isRequest) {
|
||||
// Request message - wait for response from MCP server
|
||||
const response = await this.handleMcpRequest(
|
||||
serverName,
|
||||
message,
|
||||
transport,
|
||||
);
|
||||
return { mcp_response: response };
|
||||
} else {
|
||||
// Notification or response - just route it
|
||||
transport.handleMessage(message);
|
||||
// Return acknowledgment for notifications
|
||||
return { mcp_response: { jsonrpc: '2.0', result: {}, id: 0 } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MCP request and wait for response
|
||||
*/
|
||||
private handleMcpRequest(
|
||||
_serverName: string,
|
||||
message: JSONRPCMessage,
|
||||
transport: SdkControlServerTransport,
|
||||
): Promise<JSONRPCMessage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('MCP request timeout'));
|
||||
}, 30000); // 30 seconds
|
||||
|
||||
// Store message ID for matching
|
||||
const messageId = 'id' in message ? message.id : null;
|
||||
|
||||
// Hook into transport to capture response
|
||||
const originalSend = transport.sendToQuery;
|
||||
transport.sendToQuery = async (responseMessage: JSONRPCMessage) => {
|
||||
if ('id' in responseMessage && responseMessage.id === messageId) {
|
||||
clearTimeout(timeout);
|
||||
// Restore original send
|
||||
transport.sendToQuery = originalSend;
|
||||
resolve(responseMessage);
|
||||
}
|
||||
// Forward to original handler
|
||||
return originalSend(responseMessage);
|
||||
};
|
||||
|
||||
// Send message to MCP server
|
||||
transport.handleMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control response from CLI
|
||||
*/
|
||||
private handleControlResponse(response: CLIControlResponse): void {
|
||||
const { response: payload } = response;
|
||||
const request_id = payload.request_id;
|
||||
|
||||
const pending = this.pendingControlRequests.get(request_id);
|
||||
if (!pending) {
|
||||
console.warn(
|
||||
'[Query] Received response for unknown request:',
|
||||
request_id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingControlRequests.delete(request_id);
|
||||
|
||||
// Resolve or reject based on response type
|
||||
if (payload.subtype === 'success') {
|
||||
pending.resolve(payload.response as Record<string, unknown> | null);
|
||||
} else {
|
||||
// Extract error message from error field (can be string or object)
|
||||
const errorMessage =
|
||||
typeof payload.error === 'string'
|
||||
? payload.error
|
||||
: (payload.error?.message ?? 'Unknown error');
|
||||
pending.reject(new Error(errorMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control cancel request from CLI
|
||||
*/
|
||||
private handleControlCancelRequest(request: ControlCancelRequest): void {
|
||||
const { request_id } = request;
|
||||
|
||||
if (!request_id) {
|
||||
console.warn('[Query] Received cancel request without request_id');
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pendingControlRequests.get(request_id);
|
||||
if (pending) {
|
||||
// Abort the request
|
||||
pending.abortController.abort();
|
||||
|
||||
// Clean up
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingControlRequests.delete(request_id);
|
||||
|
||||
// Reject with abort error
|
||||
pending.reject(new AbortError('Request cancelled'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hook callback request
|
||||
*/
|
||||
private async handleHookCallback(
|
||||
callbackId: string,
|
||||
input: unknown,
|
||||
toolUseId: string | null,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const callback = this.hookCallbacks.get(callbackId);
|
||||
if (!callback) {
|
||||
throw new Error(`No hook callback found for ID: ${callbackId}`);
|
||||
}
|
||||
|
||||
// Invoke callback with signal
|
||||
const result = await callback(input, toolUseId, { signal });
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send control request to CLI
|
||||
*/
|
||||
private async sendControlRequest(
|
||||
subtype: string,
|
||||
data: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const requestId = randomUUID();
|
||||
|
||||
const request: CLIControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: requestId,
|
||||
request: {
|
||||
subtype: subtype as never, // Type assertion needed for dynamic subtype
|
||||
...data,
|
||||
} as CLIControlRequest['request'],
|
||||
};
|
||||
|
||||
// Create promise for response
|
||||
const responsePromise = new Promise<Record<string, unknown> | null>(
|
||||
(resolve, reject) => {
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingControlRequests.delete(requestId);
|
||||
reject(new Error(`Control request timeout: ${subtype}`));
|
||||
}, 300000); // 30 seconds
|
||||
|
||||
this.pendingControlRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
abortController,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Send request
|
||||
this.transport.write(serializeJsonLine(request));
|
||||
|
||||
// Wait for response
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send control response to CLI
|
||||
*/
|
||||
private async sendControlResponse(
|
||||
requestId: string,
|
||||
success: boolean,
|
||||
responseOrError: Record<string, unknown> | null | string,
|
||||
): Promise<void> {
|
||||
const response: CLIControlResponse = {
|
||||
type: 'control_response',
|
||||
response: success
|
||||
? {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: responseOrError as Record<string, unknown> | null,
|
||||
}
|
||||
: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error: responseOrError as string,
|
||||
},
|
||||
};
|
||||
|
||||
this.transport.write(serializeJsonLine(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the query and cleanup resources
|
||||
*
|
||||
* Idempotent - safe to call multiple times.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return; // Already closed
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
|
||||
// Cancel pending control requests
|
||||
for (const pending of this.pendingControlRequests.values()) {
|
||||
pending.abortController.abort();
|
||||
clearTimeout(pending.timeout);
|
||||
}
|
||||
this.pendingControlRequests.clear();
|
||||
|
||||
// Clear hook callbacks
|
||||
this.hookCallbacks.clear();
|
||||
|
||||
// Close transport
|
||||
await this.transport.close();
|
||||
|
||||
// Complete input stream - check if aborted first
|
||||
if (!this.inputStream.hasError) {
|
||||
if (this.abortController.signal.aborted) {
|
||||
this.inputStream.setError(new AbortError('Query aborted'));
|
||||
} else {
|
||||
this.inputStream.done();
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup MCP transports
|
||||
for (const transport of this.sdkMcpTransports.values()) {
|
||||
try {
|
||||
await transport.close();
|
||||
} catch (error) {
|
||||
console.error('[Query] Error closing MCP transport:', error);
|
||||
}
|
||||
}
|
||||
this.sdkMcpTransports.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* AsyncIterator protocol: next()
|
||||
*/
|
||||
async next(): Promise<IteratorResult<CLIMessage>> {
|
||||
// Wait for initialization to complete if still in progress
|
||||
if (this.initialized) {
|
||||
await this.initialized;
|
||||
}
|
||||
|
||||
return this.inputStream.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* AsyncIterable protocol: Symbol.asyncIterator
|
||||
*/
|
||||
[Symbol.asyncIterator](): AsyncIterator<CLIMessage> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send follow-up messages for multi-turn conversations
|
||||
*
|
||||
* @param messages - Async iterable of user messages to send
|
||||
* @throws Error if query is closed
|
||||
*/
|
||||
async streamInput(messages: AsyncIterable<CLIUserMessage>): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for initialization to complete before sending messages
|
||||
// This prevents "write after end" errors when streamInput is called
|
||||
// with an empty iterable before initialization finishes
|
||||
if (this.initialized) {
|
||||
await this.initialized;
|
||||
}
|
||||
|
||||
// Send all messages
|
||||
for await (const message of messages) {
|
||||
// Check if aborted
|
||||
if (this.abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
this.transport.write(serializeJsonLine(message));
|
||||
}
|
||||
|
||||
// In multi-turn mode with MCP servers, wait for first result
|
||||
// to ensure MCP servers have time to process before next input
|
||||
if (
|
||||
!this.isSingleTurn &&
|
||||
this.sdkMcpTransports.size > 0 &&
|
||||
this.firstResultReceivedPromise
|
||||
) {
|
||||
const STREAM_CLOSE_TIMEOUT = 10000; // 10 seconds
|
||||
|
||||
await Promise.race([
|
||||
this.firstResultReceivedPromise,
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, STREAM_CLOSE_TIMEOUT);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
this.endInput();
|
||||
} catch (error) {
|
||||
// Check if aborted - if so, set abort error on stream
|
||||
if (this.abortController.signal.aborted) {
|
||||
console.log('[Query] Aborted during input streaming');
|
||||
this.inputStream.setError(
|
||||
new AbortError('Query aborted during input streaming'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End input stream (close stdin to CLI)
|
||||
*
|
||||
* @throws Error if query is closed
|
||||
*/
|
||||
endInput(): void {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
if (
|
||||
'endInput' in this.transport &&
|
||||
typeof this.transport.endInput === 'function'
|
||||
) {
|
||||
(this.transport as TransportWithEndInput).endInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt the current operation
|
||||
*
|
||||
* @throws Error if query is closed
|
||||
*/
|
||||
async interrupt(): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
await this.sendControlRequest(ControlRequestType.INTERRUPT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permission mode for tool execution
|
||||
*
|
||||
* @param mode - Permission mode ('default' | 'plan' | 'auto-edit' | 'yolo')
|
||||
* @throws Error if query is closed
|
||||
*/
|
||||
async setPermissionMode(mode: string): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
await this.sendControlRequest(ControlRequestType.SET_PERMISSION_MODE, {
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the model for the current query
|
||||
*
|
||||
* @param model - Model name (e.g., 'qwen-2.5-coder-32b-instruct')
|
||||
* @throws Error if query is closed
|
||||
*/
|
||||
async setModel(model: string): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
await this.sendControlRequest(ControlRequestType.SET_MODEL, { model });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of control commands supported by the CLI
|
||||
*
|
||||
* @returns Promise resolving to list of supported command names
|
||||
* @throws Error if query is closed
|
||||
*/
|
||||
async supportedCommands(): Promise<Record<string, unknown> | null> {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
return this.sendControlRequest(ControlRequestType.SUPPORTED_COMMANDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of MCP servers
|
||||
*
|
||||
* @returns Promise resolving to MCP server status information
|
||||
* @throws Error if query is closed
|
||||
*/
|
||||
async mcpServerStatus(): Promise<Record<string, unknown> | null> {
|
||||
if (this.closed) {
|
||||
throw new Error('Query is closed');
|
||||
}
|
||||
|
||||
return this.sendControlRequest(ControlRequestType.MCP_SERVER_STATUS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session ID for this query
|
||||
*
|
||||
* @returns UUID session identifier
|
||||
*/
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the query has been closed
|
||||
*
|
||||
* @returns true if query is closed, false otherwise
|
||||
*/
|
||||
isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* Factory function for creating Query instances.
|
||||
*/
|
||||
|
||||
import type { CLIUserMessage } from '../types/protocol.js';
|
||||
import { serializeJsonLine } from '../utils/jsonLines.js';
|
||||
import type {
|
||||
CreateQueryOptions,
|
||||
PermissionMode,
|
||||
PermissionCallback,
|
||||
ExternalMcpServerConfig,
|
||||
} from '../types/config.js';
|
||||
import { ProcessTransport } from '../transport/ProcessTransport.js';
|
||||
import { parseExecutableSpec } from '../utils/cliPath.js';
|
||||
import { Query } from './Query.js';
|
||||
|
||||
/**
|
||||
* Configuration options for creating a Query.
|
||||
*/
|
||||
export type QueryOptions = {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
pathToQwenExecutable?: string;
|
||||
env?: Record<string, string>;
|
||||
permissionMode?: PermissionMode;
|
||||
canUseTool?: PermissionCallback;
|
||||
mcpServers?: Record<string, ExternalMcpServerConfig>;
|
||||
sdkMcpServers?: Record<
|
||||
string,
|
||||
{ connect: (transport: unknown) => Promise<void> }
|
||||
>;
|
||||
abortController?: AbortController;
|
||||
debug?: boolean;
|
||||
stderr?: (message: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Query instance for interacting with the Qwen CLI.
|
||||
*
|
||||
* Supports both single-turn (string) and multi-turn (AsyncIterable) prompts.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const q = query({
|
||||
* prompt: 'What files are in this directory?',
|
||||
* options: { cwd: process.cwd() },
|
||||
* });
|
||||
*
|
||||
* for await (const msg of q) {
|
||||
* if (msg.type === 'assistant') {
|
||||
* console.log(msg.message.content);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function query({
|
||||
prompt,
|
||||
options = {},
|
||||
}: {
|
||||
prompt: string | AsyncIterable<CLIUserMessage>;
|
||||
options?: QueryOptions;
|
||||
}): Query {
|
||||
// Validate options and obtain normalized executable metadata
|
||||
const parsedExecutable = validateOptions(options);
|
||||
|
||||
// Determine if this is a single-turn or multi-turn query
|
||||
// Single-turn: string prompt (simple Q&A)
|
||||
// Multi-turn: AsyncIterable prompt (streaming conversation)
|
||||
const isSingleTurn = typeof prompt === 'string';
|
||||
|
||||
// Build CreateQueryOptions
|
||||
const queryOptions: CreateQueryOptions = {
|
||||
...options,
|
||||
singleTurn: isSingleTurn,
|
||||
};
|
||||
|
||||
// Resolve CLI specification while preserving explicit runtime directives
|
||||
const pathToQwenExecutable =
|
||||
options.pathToQwenExecutable ?? parsedExecutable.executablePath;
|
||||
|
||||
// Use provided abortController or create a new one
|
||||
const abortController = options.abortController ?? new AbortController();
|
||||
|
||||
// Create transport with abortController
|
||||
const transport = new ProcessTransport({
|
||||
pathToQwenExecutable,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
permissionMode: options.permissionMode,
|
||||
mcpServers: options.mcpServers,
|
||||
env: options.env,
|
||||
abortController,
|
||||
debug: options.debug,
|
||||
stderr: options.stderr,
|
||||
});
|
||||
|
||||
// Build query options with abortController
|
||||
const finalQueryOptions: CreateQueryOptions = {
|
||||
...queryOptions,
|
||||
abortController,
|
||||
};
|
||||
|
||||
// Create Query
|
||||
const queryInstance = new Query(transport, finalQueryOptions);
|
||||
|
||||
// Handle prompt based on type
|
||||
if (isSingleTurn) {
|
||||
// For single-turn queries, send the prompt directly via transport
|
||||
const stringPrompt = prompt as string;
|
||||
const message: CLIUserMessage = {
|
||||
type: 'user',
|
||||
session_id: queryInstance.getSessionId(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: stringPrompt,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
transport.write(serializeJsonLine(message));
|
||||
} catch (err) {
|
||||
console.error('[query] Error sending single-turn prompt:', err);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// For multi-turn queries, stream the input
|
||||
queryInstance
|
||||
.streamInput(prompt as AsyncIterable<CLIUserMessage>)
|
||||
.catch((err) => {
|
||||
console.error('[query] Error streaming input:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return queryInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward compatibility alias
|
||||
* @deprecated Use query() instead
|
||||
*/
|
||||
export const createQuery = query;
|
||||
|
||||
/**
|
||||
* Validate query configuration options and normalize CLI executable details.
|
||||
*
|
||||
* Performs strict validation for each supported option, including
|
||||
* permission mode, callbacks, AbortController usage, and executable spec.
|
||||
* Returns the parsed executable description so callers can retain
|
||||
* explicit runtime directives (e.g., `bun:/path/to/cli.js`) while still
|
||||
* benefiting from early validation and auto-detection fallbacks when the
|
||||
* specification is omitted.
|
||||
*/
|
||||
function validateOptions(
|
||||
options: QueryOptions,
|
||||
): ReturnType<typeof parseExecutableSpec> {
|
||||
let parsedExecutable: ReturnType<typeof parseExecutableSpec>;
|
||||
|
||||
// Validate permission mode if provided
|
||||
if (options.permissionMode) {
|
||||
const validModes = ['default', 'plan', 'auto-edit', 'yolo'];
|
||||
if (!validModes.includes(options.permissionMode)) {
|
||||
throw new Error(
|
||||
`Invalid permissionMode: ${options.permissionMode}. Valid values are: ${validModes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate canUseTool is a function if provided
|
||||
if (options.canUseTool && typeof options.canUseTool !== 'function') {
|
||||
throw new Error('canUseTool must be a function');
|
||||
}
|
||||
|
||||
// Validate abortController is AbortController if provided
|
||||
if (
|
||||
options.abortController &&
|
||||
!(options.abortController instanceof AbortController)
|
||||
) {
|
||||
throw new Error('abortController must be an AbortController instance');
|
||||
}
|
||||
|
||||
// Validate executable path early to provide clear error messages
|
||||
try {
|
||||
parsedExecutable = parseExecutableSpec(options.pathToQwenExecutable);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid pathToQwenExecutable: ${errorMessage}`);
|
||||
}
|
||||
|
||||
// Validate no MCP server name conflicts
|
||||
if (options.mcpServers && options.sdkMcpServers) {
|
||||
const externalNames = Object.keys(options.mcpServers);
|
||||
const sdkNames = Object.keys(options.sdkMcpServers);
|
||||
|
||||
const conflicts = externalNames.filter((name) => sdkNames.includes(name));
|
||||
if (conflicts.length > 0) {
|
||||
throw new Error(
|
||||
`MCP server name conflicts between mcpServers and sdkMcpServers: ${conflicts.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return parsedExecutable;
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
/**
|
||||
* ProcessTransport - Subprocess-based transport for SDK-CLI communication
|
||||
*
|
||||
* Manages CLI subprocess lifecycle and provides IPC via stdin/stdout using JSON Lines protocol.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import * as readline from 'node:readline';
|
||||
import type { Writable, Readable } from 'node:stream';
|
||||
import type { TransportOptions } from '../types/config.js';
|
||||
import type { Transport } from './Transport.js';
|
||||
import { parseJsonLinesStream } from '../utils/jsonLines.js';
|
||||
import { prepareSpawnInfo } from '../utils/cliPath.js';
|
||||
import { AbortError } from '../types/errors.js';
|
||||
|
||||
/**
|
||||
* Exit listener type
|
||||
*/
|
||||
type ExitListener = {
|
||||
callback: (error?: Error) => void;
|
||||
handler: (code: number | null, signal: NodeJS.Signals | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* ProcessTransport implementation
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. Created with options
|
||||
* 2. start() spawns subprocess
|
||||
* 3. isReady becomes true
|
||||
* 4. write() sends messages to stdin
|
||||
* 5. readMessages() yields messages from stdout
|
||||
* 6. close() gracefully shuts down (SIGTERM → SIGKILL)
|
||||
* 7. waitForExit() resolves when cleanup complete
|
||||
*/
|
||||
export class ProcessTransport implements Transport {
|
||||
private childProcess: ChildProcess | null = null;
|
||||
private options: TransportOptions;
|
||||
private _isReady = false;
|
||||
private _exitError: Error | null = null;
|
||||
private exitPromise: Promise<void> | null = null;
|
||||
private exitResolve: (() => void) | null = null;
|
||||
private cleanupCallbacks: Array<() => void> = [];
|
||||
private closed = false;
|
||||
private abortController: AbortController | null = null;
|
||||
private exitListeners: ExitListener[] = [];
|
||||
|
||||
constructor(options: TransportOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the transport by spawning CLI subprocess
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.childProcess) {
|
||||
return; // Already started
|
||||
}
|
||||
|
||||
// Use provided abortController or create a new one
|
||||
this.abortController =
|
||||
this.options.abortController ?? new AbortController();
|
||||
|
||||
// Check if already aborted
|
||||
if (this.abortController.signal.aborted) {
|
||||
throw new AbortError('Transport start aborted');
|
||||
}
|
||||
|
||||
const cliArgs = this.buildCliArguments();
|
||||
const cwd = this.options.cwd ?? process.cwd();
|
||||
const env = { ...process.env, ...this.options.env };
|
||||
|
||||
// Setup abort handler
|
||||
this.abortController.signal.addEventListener('abort', () => {
|
||||
this.logForDebugging('Transport aborted by user');
|
||||
this._exitError = new AbortError('Operation aborted by user');
|
||||
this._isReady = false;
|
||||
void this.close();
|
||||
});
|
||||
|
||||
// Create exit promise
|
||||
this.exitPromise = new Promise<void>((resolve) => {
|
||||
this.exitResolve = resolve;
|
||||
});
|
||||
|
||||
try {
|
||||
// Detect executable type and prepare spawn info
|
||||
const spawnInfo = prepareSpawnInfo(this.options.pathToQwenExecutable);
|
||||
|
||||
const stderrMode =
|
||||
this.options.debug || this.options.stderr ? 'pipe' : 'ignore';
|
||||
|
||||
this.logForDebugging(
|
||||
`Spawning CLI (${spawnInfo.type}): ${spawnInfo.command} ${[...spawnInfo.args, ...cliArgs].join(' ')}`,
|
||||
);
|
||||
|
||||
// Spawn CLI subprocess with appropriate command and args
|
||||
this.childProcess = spawn(
|
||||
spawnInfo.command,
|
||||
[...spawnInfo.args, ...cliArgs],
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', stderrMode],
|
||||
// Use AbortController signal
|
||||
signal: this.abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle stderr for debugging
|
||||
if (this.options.debug || this.options.stderr) {
|
||||
this.childProcess.stderr?.on('data', (data) => {
|
||||
this.logForDebugging(data.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Setup event handlers
|
||||
this.setupEventHandlers();
|
||||
|
||||
// Mark as ready
|
||||
this._isReady = true;
|
||||
|
||||
// Register cleanup on parent process exit
|
||||
this.registerParentExitHandler();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to spawn CLI process: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for child process
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.childProcess) return;
|
||||
|
||||
// Handle process errors
|
||||
this.childProcess.on('error', (error) => {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
this._exitError = new AbortError('CLI process aborted by user');
|
||||
} else {
|
||||
this._exitError = new Error(`CLI process error: ${error.message}`);
|
||||
}
|
||||
this._isReady = false;
|
||||
this.logForDebugging(`Process error: ${error.message}`);
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
this.childProcess.on('exit', (code, signal) => {
|
||||
this._isReady = false;
|
||||
|
||||
// Check if aborted
|
||||
if (this.abortController?.signal.aborted) {
|
||||
this._exitError = new AbortError('CLI process aborted by user');
|
||||
} else if (code !== null && code !== 0 && !this.closed) {
|
||||
this._exitError = new Error(`CLI process exited with code ${code}`);
|
||||
this.logForDebugging(`Process exited with code ${code}`);
|
||||
} else if (signal && !this.closed) {
|
||||
this._exitError = new Error(`CLI process killed by signal ${signal}`);
|
||||
this.logForDebugging(`Process killed by signal ${signal}`);
|
||||
}
|
||||
|
||||
// Notify exit listeners
|
||||
const error = this._exitError;
|
||||
for (const listener of this.exitListeners) {
|
||||
try {
|
||||
listener.callback(error || undefined);
|
||||
} catch (err) {
|
||||
this.logForDebugging(`Exit listener error: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve exit promise
|
||||
if (this.exitResolve) {
|
||||
this.exitResolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cleanup handler on parent process exit
|
||||
*/
|
||||
private registerParentExitHandler(): void {
|
||||
const cleanup = (): void => {
|
||||
if (this.childProcess && !this.childProcess.killed) {
|
||||
this.childProcess.kill('SIGKILL');
|
||||
}
|
||||
};
|
||||
|
||||
process.on('exit', cleanup);
|
||||
this.cleanupCallbacks.push(() => {
|
||||
process.off('exit', cleanup);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CLI command-line arguments
|
||||
*/
|
||||
private buildCliArguments(): string[] {
|
||||
const args: string[] = [
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
];
|
||||
|
||||
// Add model if specified
|
||||
if (this.options.model) {
|
||||
args.push('--model', this.options.model);
|
||||
}
|
||||
|
||||
// Add permission mode if specified
|
||||
if (this.options.permissionMode) {
|
||||
args.push('--approval-mode', this.options.permissionMode);
|
||||
}
|
||||
|
||||
// Add MCP servers if specified
|
||||
if (this.options.mcpServers) {
|
||||
for (const [name, config] of Object.entries(this.options.mcpServers)) {
|
||||
args.push('--mcp-server', JSON.stringify({ name, ...config }));
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the transport gracefully
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.closed || !this.childProcess) {
|
||||
return; // Already closed or never started
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
this._isReady = false;
|
||||
|
||||
// Clean up exit listeners
|
||||
for (const { handler } of this.exitListeners) {
|
||||
this.childProcess?.off('exit', handler);
|
||||
}
|
||||
this.exitListeners = [];
|
||||
|
||||
// Send SIGTERM for graceful shutdown
|
||||
this.childProcess.kill('SIGTERM');
|
||||
|
||||
// Wait 5 seconds, then force kill if still alive
|
||||
const forceKillTimeout = setTimeout(() => {
|
||||
if (this.childProcess && !this.childProcess.killed) {
|
||||
this.childProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Wait for exit
|
||||
await this.waitForExit();
|
||||
|
||||
// Clear timeout
|
||||
clearTimeout(forceKillTimeout);
|
||||
|
||||
// Run cleanup callbacks
|
||||
for (const callback of this.cleanupCallbacks) {
|
||||
callback();
|
||||
}
|
||||
this.cleanupCallbacks = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for process to fully exit
|
||||
*/
|
||||
async waitForExit(): Promise<void> {
|
||||
if (this.exitPromise) {
|
||||
await this.exitPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a message to stdin
|
||||
*/
|
||||
write(message: string): void {
|
||||
// Check abort status
|
||||
if (this.abortController?.signal.aborted) {
|
||||
throw new AbortError('Cannot write: operation aborted');
|
||||
}
|
||||
|
||||
if (!this._isReady || !this.childProcess?.stdin) {
|
||||
throw new Error('Transport not ready for writing');
|
||||
}
|
||||
|
||||
if (this.closed) {
|
||||
throw new Error('Cannot write to closed transport');
|
||||
}
|
||||
|
||||
if (this.childProcess?.killed || this.childProcess?.exitCode !== null) {
|
||||
throw new Error('Cannot write to terminated process');
|
||||
}
|
||||
|
||||
if (this._exitError) {
|
||||
throw new Error(
|
||||
`Cannot write to process that exited with error: ${this._exitError.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env['DEBUG_SDK']) {
|
||||
this.logForDebugging(
|
||||
`[ProcessTransport] Writing to stdin: ${message.substring(0, 100)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const written = this.childProcess.stdin.write(message + '\n', (err) => {
|
||||
if (err) {
|
||||
throw new Error(`Failed to write to stdin: ${err.message}`);
|
||||
}
|
||||
});
|
||||
if (!written && process.env['DEBUG_SDK']) {
|
||||
this.logForDebugging(
|
||||
'[ProcessTransport] Write buffer full, data queued',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this._isReady = false;
|
||||
throw new Error(
|
||||
`Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read messages from stdout as async generator
|
||||
*/
|
||||
async *readMessages(): AsyncGenerator<unknown, void, unknown> {
|
||||
if (!this.childProcess?.stdout) {
|
||||
throw new Error('Cannot read messages: process not started');
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: this.childProcess.stdout,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
try {
|
||||
// Use JSON Lines parser
|
||||
for await (const message of parseJsonLinesStream(
|
||||
rl,
|
||||
'ProcessTransport',
|
||||
)) {
|
||||
yield message;
|
||||
}
|
||||
|
||||
await this.waitForExit();
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if transport is ready for I/O
|
||||
*/
|
||||
get isReady(): boolean {
|
||||
return this._isReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exit error (if any)
|
||||
*/
|
||||
get exitError(): Error | null {
|
||||
return this._exitError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get child process (for testing)
|
||||
*/
|
||||
get process(): ChildProcess | null {
|
||||
return this.childProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to qwen executable
|
||||
*/
|
||||
get pathToQwenExecutable(): string {
|
||||
return this.options.pathToQwenExecutable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLI arguments
|
||||
*/
|
||||
get cliArgs(): readonly string[] {
|
||||
return this.buildCliArguments();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get working directory
|
||||
*/
|
||||
get cwd(): string {
|
||||
return this.options.cwd ?? process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be invoked when the process exits
|
||||
*
|
||||
* @param callback - Function to call on exit, receives error if abnormal exit
|
||||
* @returns Cleanup function to remove the listener
|
||||
*/
|
||||
onExit(callback: (error?: Error) => void): () => void {
|
||||
if (!this.childProcess) {
|
||||
return () => {}; // No-op if process not started
|
||||
}
|
||||
|
||||
const handler = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
let error: Error | undefined;
|
||||
|
||||
if (this.abortController?.signal.aborted) {
|
||||
error = new AbortError('Process aborted by user');
|
||||
} else if (code !== null && code !== 0) {
|
||||
error = new Error(`Process exited with code ${code}`);
|
||||
} else if (signal) {
|
||||
error = new Error(`Process killed by signal ${signal}`);
|
||||
}
|
||||
|
||||
callback(error);
|
||||
};
|
||||
|
||||
this.childProcess.on('exit', handler);
|
||||
this.exitListeners.push({ callback, handler });
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
if (this.childProcess) {
|
||||
this.childProcess.off('exit', handler);
|
||||
}
|
||||
const index = this.exitListeners.findIndex((l) => l.handler === handler);
|
||||
if (index !== -1) {
|
||||
this.exitListeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* End input stream (close stdin)
|
||||
* Useful when you want to signal no more input will be sent
|
||||
*/
|
||||
endInput(): void {
|
||||
if (this.childProcess?.stdin) {
|
||||
this.childProcess.stdin.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direct access to stdin stream
|
||||
* Use with caution - prefer write() method for normal use
|
||||
*
|
||||
* @returns Writable stream for stdin, or undefined if not available
|
||||
*/
|
||||
getInputStream(): Writable | undefined {
|
||||
return this.childProcess?.stdin || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get direct access to stdout stream
|
||||
* Use with caution - prefer readMessages() for normal use
|
||||
*
|
||||
* @returns Readable stream for stdout, or undefined if not available
|
||||
*/
|
||||
getOutputStream(): Readable | undefined {
|
||||
return this.childProcess?.stdout || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message for debugging (if debug enabled)
|
||||
*/
|
||||
private logForDebugging(message: string): void {
|
||||
if (this.options.debug || process.env['DEBUG']) {
|
||||
process.stderr.write(`[ProcessTransport] ${message}\n`);
|
||||
}
|
||||
if (this.options.stderr) {
|
||||
this.options.stderr(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Transport interface for SDK-CLI communication
|
||||
*
|
||||
* The Transport abstraction enables communication between SDK and CLI via different mechanisms:
|
||||
* - ProcessTransport: Local subprocess via stdin/stdout (initial implementation)
|
||||
* - HttpTransport: Remote CLI via HTTP (future)
|
||||
* - WebSocketTransport: Remote CLI via WebSocket (future)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Abstract Transport interface
|
||||
*
|
||||
* Provides bidirectional communication with lifecycle management.
|
||||
* Implements async generator pattern for reading messages with automatic backpressure.
|
||||
*/
|
||||
export interface Transport {
|
||||
/**
|
||||
* Initialize and start the transport.
|
||||
*
|
||||
* For ProcessTransport: spawns CLI subprocess
|
||||
* For HttpTransport: establishes HTTP connection
|
||||
* For WebSocketTransport: opens WebSocket connection
|
||||
*
|
||||
* Must be called before write() or readMessages().
|
||||
*
|
||||
* @throws Error if transport cannot be started
|
||||
*/
|
||||
start(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Close the transport gracefully.
|
||||
*
|
||||
* For ProcessTransport: sends SIGTERM, waits 5s, then SIGKILL
|
||||
* For HttpTransport: sends close request, closes connection
|
||||
* For WebSocketTransport: sends close frame
|
||||
*
|
||||
* Idempotent - safe to call multiple times.
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Wait for transport to fully exit and cleanup.
|
||||
*
|
||||
* Resolves when all resources are cleaned up:
|
||||
* - Process has exited (ProcessTransport)
|
||||
* - Connection is closed (Http/WebSocketTransport)
|
||||
* - All cleanup callbacks have run
|
||||
*
|
||||
* @returns Promise that resolves when exit is complete
|
||||
*/
|
||||
waitForExit(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Write a message to the transport.
|
||||
*
|
||||
* For ProcessTransport: writes to stdin
|
||||
* For HttpTransport: sends HTTP request
|
||||
* For WebSocketTransport: sends WebSocket message
|
||||
*
|
||||
* Message format: JSON Lines (one JSON object per line)
|
||||
*
|
||||
* @param message - Serialized JSON message (without trailing newline)
|
||||
* @throws Error if transport is not ready or closed
|
||||
*/
|
||||
write(message: string): void;
|
||||
|
||||
/**
|
||||
* Read messages from transport as async generator.
|
||||
*
|
||||
* Yields messages as they arrive, supporting natural backpressure via async iteration.
|
||||
* Generator completes when transport closes.
|
||||
*
|
||||
* For ProcessTransport: reads from stdout using readline
|
||||
* For HttpTransport: reads from chunked HTTP response
|
||||
* For WebSocketTransport: reads from WebSocket messages
|
||||
*
|
||||
* Message format: JSON Lines (one JSON object per line)
|
||||
* Malformed JSON lines are logged and skipped.
|
||||
*
|
||||
* @yields Parsed JSON messages
|
||||
* @throws Error if transport encounters fatal error
|
||||
*/
|
||||
readMessages(): AsyncGenerator<unknown, void, unknown>;
|
||||
|
||||
/**
|
||||
* Whether transport is ready for I/O operations.
|
||||
*
|
||||
* true: write() and readMessages() can be called
|
||||
* false: transport not started or has failed
|
||||
*/
|
||||
readonly isReady: boolean;
|
||||
|
||||
/**
|
||||
* Error that caused transport to exit unexpectedly (if any).
|
||||
*
|
||||
* null: transport exited normally or still running
|
||||
* Error: transport failed with this error
|
||||
*
|
||||
* Useful for diagnostics when transport closes unexpectedly.
|
||||
*/
|
||||
readonly exitError: Error | null;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Configuration types for SDK
|
||||
*/
|
||||
|
||||
import type { ToolDefinition as ToolDef } from './mcp.js';
|
||||
import type { PermissionMode } from './protocol.js';
|
||||
|
||||
export type { ToolDef as ToolDefinition };
|
||||
export type { PermissionMode };
|
||||
|
||||
/**
|
||||
* Permission callback function
|
||||
* Called before each tool execution to determine if it should be allowed
|
||||
*
|
||||
* @param toolName - Name of the tool being executed
|
||||
* @param input - Input parameters for the tool
|
||||
* @param options - Additional options (signal for cancellation, suggestions)
|
||||
* @returns Promise<boolean|unknown> or boolean|unknown - true to allow, false to deny, or custom response
|
||||
*/
|
||||
export type PermissionCallback = (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
suggestions?: unknown;
|
||||
},
|
||||
) => Promise<boolean | unknown> | boolean | unknown;
|
||||
|
||||
/**
|
||||
* Hook callback function
|
||||
* Called at specific points in tool execution lifecycle
|
||||
*
|
||||
* @param input - Hook input data
|
||||
* @param toolUseId - Tool execution ID (null if not associated with a tool)
|
||||
* @param options - Options including abort signal
|
||||
* @returns Promise with hook result
|
||||
*/
|
||||
export type HookCallback = (
|
||||
input: unknown,
|
||||
toolUseId: string | null,
|
||||
options: { signal: AbortSignal },
|
||||
) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Hook matcher configuration
|
||||
*/
|
||||
export interface HookMatcher {
|
||||
matcher: Record<string, unknown>;
|
||||
hooks: HookCallback[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook configuration by event type
|
||||
*/
|
||||
export type HookConfig = {
|
||||
[event: string]: HookMatcher[];
|
||||
};
|
||||
|
||||
/**
|
||||
* External MCP server configuration (spawned by CLI)
|
||||
*/
|
||||
export type ExternalMcpServerConfig = {
|
||||
/** Command to execute (e.g., 'mcp-server-filesystem') */
|
||||
command: string;
|
||||
/** Command-line arguments */
|
||||
args?: string[];
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for creating a Query instance
|
||||
*/
|
||||
export type CreateQueryOptions = {
|
||||
// Basic configuration
|
||||
/** Working directory for CLI execution */
|
||||
cwd?: string;
|
||||
/** Model name (e.g., 'qwen-2.5-coder-32b-instruct') */
|
||||
model?: string;
|
||||
|
||||
// Transport configuration
|
||||
/** Path to qwen executable (auto-detected if omitted) */
|
||||
pathToQwenExecutable?: string;
|
||||
/** Environment variables for CLI process */
|
||||
env?: Record<string, string>;
|
||||
|
||||
// Permission control
|
||||
/** Permission mode ('default' | 'plan' | 'auto-edit' | 'yolo') */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Callback invoked before each tool execution */
|
||||
canUseTool?: PermissionCallback;
|
||||
|
||||
// Hook system
|
||||
/** Hook configuration for tool execution lifecycle */
|
||||
hooks?: HookConfig;
|
||||
|
||||
// MCP server configuration
|
||||
/** External MCP servers (spawned by CLI) */
|
||||
mcpServers?: Record<string, ExternalMcpServerConfig>;
|
||||
/** SDK-embedded MCP servers (run in Node.js process) */
|
||||
sdkMcpServers?: Record<
|
||||
string,
|
||||
{ connect: (transport: unknown) => Promise<void> }
|
||||
>; // Server from @modelcontextprotocol/sdk
|
||||
|
||||
// Conversation mode
|
||||
/**
|
||||
* Single-turn mode: automatically close input after receiving result
|
||||
* Multi-turn mode: keep input open for follow-up messages
|
||||
* @default false (multi-turn)
|
||||
*/
|
||||
singleTurn?: boolean;
|
||||
|
||||
// Advanced options
|
||||
/** AbortController for cancellation support */
|
||||
abortController?: AbortController;
|
||||
/** Enable debug output (inherits stderr) */
|
||||
debug?: boolean;
|
||||
/** Callback for stderr output */
|
||||
stderr?: (message: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transport options for ProcessTransport
|
||||
*/
|
||||
export type TransportOptions = {
|
||||
/** Path to qwen executable */
|
||||
pathToQwenExecutable: string;
|
||||
/** Working directory for CLI execution */
|
||||
cwd?: string;
|
||||
/** Model name */
|
||||
model?: string;
|
||||
/** Permission mode */
|
||||
permissionMode?: PermissionMode;
|
||||
/** External MCP servers */
|
||||
mcpServers?: Record<string, ExternalMcpServerConfig>;
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** AbortController for cancellation support */
|
||||
abortController?: AbortController;
|
||||
/** Enable debug output */
|
||||
debug?: boolean;
|
||||
/** Callback for stderr output */
|
||||
stderr?: (message: string) => void;
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Control Request Types
|
||||
*
|
||||
* Centralized enum for all control request subtypes supported by the CLI.
|
||||
* This enum should be kept in sync with the controllers in:
|
||||
* - packages/cli/src/services/control/controllers/systemController.ts
|
||||
* - packages/cli/src/services/control/controllers/permissionController.ts
|
||||
* - packages/cli/src/services/control/controllers/mcpController.ts
|
||||
* - packages/cli/src/services/control/controllers/hookController.ts
|
||||
*/
|
||||
export enum ControlRequestType {
|
||||
// SystemController requests
|
||||
INITIALIZE = 'initialize',
|
||||
INTERRUPT = 'interrupt',
|
||||
SET_MODEL = 'set_model',
|
||||
SUPPORTED_COMMANDS = 'supported_commands',
|
||||
|
||||
// PermissionController requests
|
||||
CAN_USE_TOOL = 'can_use_tool',
|
||||
SET_PERMISSION_MODE = 'set_permission_mode',
|
||||
|
||||
// MCPController requests
|
||||
MCP_MESSAGE = 'mcp_message',
|
||||
MCP_SERVER_STATUS = 'mcp_server_status',
|
||||
|
||||
// HookController requests
|
||||
HOOK_CALLBACK = 'hook_callback',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available control request types as a string array
|
||||
*/
|
||||
export function getAllControlRequestTypes(): string[] {
|
||||
return Object.values(ControlRequestType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid control request type
|
||||
*/
|
||||
export function isValidControlRequestType(
|
||||
type: string,
|
||||
): type is ControlRequestType {
|
||||
return getAllControlRequestTypes().includes(type);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Error types for SDK
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error thrown when an operation is aborted via AbortSignal
|
||||
*/
|
||||
export class AbortError extends Error {
|
||||
constructor(message = 'Operation aborted') {
|
||||
super(message);
|
||||
this.name = 'AbortError';
|
||||
Object.setPrototypeOf(this, AbortError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an AbortError
|
||||
*/
|
||||
export function isAbortError(error: unknown): error is AbortError {
|
||||
return (
|
||||
error instanceof AbortError ||
|
||||
(typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
error.name === 'AbortError')
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* MCP integration types for SDK
|
||||
*/
|
||||
|
||||
/**
|
||||
* JSON Schema definition
|
||||
* Used for tool input validation
|
||||
*/
|
||||
export type JSONSchema = {
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool definition for SDK-embedded MCP servers
|
||||
*
|
||||
* @template TInput - Type of tool input (inferred from handler)
|
||||
* @template TOutput - Type of tool output (inferred from handler return)
|
||||
*/
|
||||
export type ToolDefinition<TInput = unknown, TOutput = unknown> = {
|
||||
/** Unique tool name */
|
||||
name: string;
|
||||
/** Human-readable description (helps agent decide when to use it) */
|
||||
description: string;
|
||||
/** JSON Schema for input validation */
|
||||
inputSchema: JSONSchema;
|
||||
/** Async handler function that executes the tool */
|
||||
handler: (input: TInput) => Promise<TOutput>;
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Protocol types for SDK-CLI communication
|
||||
*
|
||||
* Re-exports protocol types from CLI package to ensure SDK and CLI use identical types.
|
||||
*/
|
||||
|
||||
export type {
|
||||
ContentBlock,
|
||||
TextBlock,
|
||||
ThinkingBlock,
|
||||
ToolUseBlock,
|
||||
ToolResultBlock,
|
||||
CLIUserMessage,
|
||||
CLIAssistantMessage,
|
||||
CLISystemMessage,
|
||||
CLIResultMessage,
|
||||
CLIPartialAssistantMessage,
|
||||
CLIMessage,
|
||||
PermissionMode,
|
||||
PermissionSuggestion,
|
||||
PermissionApproval,
|
||||
HookRegistration,
|
||||
CLIControlInterruptRequest,
|
||||
CLIControlPermissionRequest,
|
||||
CLIControlInitializeRequest,
|
||||
CLIControlSetPermissionModeRequest,
|
||||
CLIHookCallbackRequest,
|
||||
CLIControlMcpMessageRequest,
|
||||
CLIControlSetModelRequest,
|
||||
CLIControlMcpStatusRequest,
|
||||
CLIControlSupportedCommandsRequest,
|
||||
ControlRequestPayload,
|
||||
CLIControlRequest,
|
||||
ControlResponse,
|
||||
ControlErrorResponse,
|
||||
CLIControlResponse,
|
||||
ControlCancelRequest,
|
||||
ControlMessage,
|
||||
} from '@qwen-code/qwen-code/protocol';
|
||||
|
||||
export {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
} from '@qwen-code/qwen-code/protocol';
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Async iterable queue for streaming messages between producer and consumer.
|
||||
*/
|
||||
|
||||
export class Stream<T> implements AsyncIterable<T> {
|
||||
private queue: T[] = [];
|
||||
private isDone = false;
|
||||
private streamError: Error | null = null;
|
||||
private readResolve: ((result: IteratorResult<T>) => void) | null = null;
|
||||
private readReject: ((error: Error) => void) | null = null;
|
||||
private maxQueueSize: number = 10000; // Prevent memory leaks
|
||||
private droppedMessageCount = 0;
|
||||
|
||||
/**
|
||||
* Add a value to the stream.
|
||||
*/
|
||||
enqueue(value: T): void {
|
||||
if (this.isDone) {
|
||||
throw new Error('Cannot enqueue to completed stream');
|
||||
}
|
||||
if (this.streamError) {
|
||||
throw new Error('Cannot enqueue to stream with error');
|
||||
}
|
||||
|
||||
// Fast path: consumer is waiting
|
||||
if (this.readResolve) {
|
||||
this.readResolve({ value, done: false });
|
||||
this.readResolve = null;
|
||||
this.readReject = null;
|
||||
} else {
|
||||
// Slow path: buffer in queue (with size limit)
|
||||
if (this.queue.length >= this.maxQueueSize) {
|
||||
// Drop oldest message to prevent memory leak
|
||||
this.queue.shift();
|
||||
this.droppedMessageCount++;
|
||||
|
||||
// Warn about dropped messages (but don't throw)
|
||||
if (this.droppedMessageCount % 100 === 1) {
|
||||
console.warn(
|
||||
`[Stream] Queue full, dropped ${this.droppedMessageCount} messages. ` +
|
||||
`Consumer may be too slow.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.queue.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the stream as complete.
|
||||
*/
|
||||
done(): void {
|
||||
if (this.isDone) {
|
||||
return; // Already done, no-op
|
||||
}
|
||||
|
||||
this.isDone = true;
|
||||
|
||||
// If consumer is waiting, signal completion
|
||||
if (this.readResolve) {
|
||||
this.readResolve({ done: true, value: undefined });
|
||||
this.readResolve = null;
|
||||
this.readReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an error state for the stream.
|
||||
*/
|
||||
setError(err: Error): void {
|
||||
if (this.streamError) {
|
||||
return; // Already has error, no-op
|
||||
}
|
||||
|
||||
this.streamError = err;
|
||||
|
||||
// If consumer is waiting, reject immediately
|
||||
if (this.readReject) {
|
||||
this.readReject(err);
|
||||
this.readResolve = null;
|
||||
this.readReject = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next value from the stream.
|
||||
*/
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
// Fast path: queue has values
|
||||
if (this.queue.length > 0) {
|
||||
const value = this.queue.shift()!;
|
||||
return { value, done: false };
|
||||
}
|
||||
|
||||
// Error path: stream has error
|
||||
if (this.streamError) {
|
||||
throw this.streamError;
|
||||
}
|
||||
|
||||
// Done path: stream is complete
|
||||
if (this.isDone) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
|
||||
// Wait path: no values yet, wait for producer
|
||||
return new Promise<IteratorResult<T>>((resolve, reject) => {
|
||||
this.readResolve = resolve;
|
||||
this.readReject = reject;
|
||||
// Producer will call resolve/reject when value/done/error occurs
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable async iteration with `for await` syntax.
|
||||
*/
|
||||
[Symbol.asyncIterator](): AsyncIterator<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
get queueSize(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
get isComplete(): boolean {
|
||||
return this.isDone;
|
||||
}
|
||||
|
||||
get hasError(): boolean {
|
||||
return this.streamError !== null;
|
||||
}
|
||||
|
||||
get droppedMessages(): number {
|
||||
return this.droppedMessageCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the maximum queue size.
|
||||
*/
|
||||
setMaxQueueSize(size: number): void {
|
||||
if (size < 1) {
|
||||
throw new Error('Max queue size must be at least 1');
|
||||
}
|
||||
this.maxQueueSize = size;
|
||||
}
|
||||
|
||||
get maxSize(): number {
|
||||
return this.maxQueueSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all buffered messages. Use only during cleanup or error recovery.
|
||||
*/
|
||||
clear(): void {
|
||||
this.queue = [];
|
||||
}
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
/**
|
||||
* CLI path auto-detection and subprocess spawning utilities
|
||||
*
|
||||
* Supports multiple execution modes:
|
||||
* 1. Native binary: 'qwen' (production)
|
||||
* 2. Node.js bundle: 'node /path/to/cli.js' (production validation)
|
||||
* 3. Bun bundle: 'bun /path/to/cli.js' (alternative runtime)
|
||||
* 4. TypeScript source: 'tsx /path/to/index.ts' (development)
|
||||
*
|
||||
* Auto-detection locations for native binary:
|
||||
* 1. QWEN_CODE_CLI_PATH environment variable
|
||||
* 2. ~/.volta/bin/qwen
|
||||
* 3. ~/.npm-global/bin/qwen
|
||||
* 4. /usr/local/bin/qwen
|
||||
* 5. ~/.local/bin/qwen
|
||||
* 6. ~/node_modules/.bin/qwen
|
||||
* 7. ~/.yarn/bin/qwen
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Executable types supported by the SDK
|
||||
*/
|
||||
export type ExecutableType = 'native' | 'node' | 'bun' | 'tsx' | 'deno';
|
||||
|
||||
/**
|
||||
* Spawn information for CLI process
|
||||
*/
|
||||
export type SpawnInfo = {
|
||||
/** Command to execute (e.g., 'qwen', 'node', 'bun', 'tsx') */
|
||||
command: string;
|
||||
/** Arguments to pass to command */
|
||||
args: string[];
|
||||
/** Type of executable detected */
|
||||
type: ExecutableType;
|
||||
/** Original input that was resolved */
|
||||
originalInput: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find native CLI executable path
|
||||
*
|
||||
* Searches global installation locations in order of priority.
|
||||
* Only looks for native 'qwen' binary, not JS/TS files.
|
||||
*
|
||||
* @returns Absolute path to CLI executable
|
||||
* @throws Error if CLI not found
|
||||
*/
|
||||
export function findNativeCliPath(): string {
|
||||
const homeDir = process.env['HOME'] || process.env['USERPROFILE'] || '';
|
||||
|
||||
const candidates: Array<string | undefined> = [
|
||||
// 1. Environment variable (highest priority)
|
||||
process.env['QWEN_CODE_CLI_PATH'],
|
||||
|
||||
// 2. Volta bin
|
||||
path.join(homeDir, '.volta', 'bin', 'qwen'),
|
||||
|
||||
// 3. Global npm installations
|
||||
path.join(homeDir, '.npm-global', 'bin', 'qwen'),
|
||||
|
||||
// 4. Common Unix binary locations
|
||||
'/usr/local/bin/qwen',
|
||||
|
||||
// 5. User local bin
|
||||
path.join(homeDir, '.local', 'bin', 'qwen'),
|
||||
|
||||
// 6. Node modules bin in home directory
|
||||
path.join(homeDir, 'node_modules', '.bin', 'qwen'),
|
||||
|
||||
// 7. Yarn global bin
|
||||
path.join(homeDir, '.yarn', 'bin', 'qwen'),
|
||||
];
|
||||
|
||||
// Find first existing candidate
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && fs.existsSync(candidate)) {
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Not found - throw helpful error
|
||||
throw new Error(
|
||||
'qwen CLI not found. Please:\n' +
|
||||
' 1. Install qwen globally: npm install -g qwen\n' +
|
||||
' 2. Or provide explicit executable: query({ pathToQwenExecutable: "/path/to/qwen" })\n' +
|
||||
' 3. Or set environment variable: QWEN_CODE_CLI_PATH="/path/to/qwen"\n' +
|
||||
'\n' +
|
||||
'For development/testing, you can also use:\n' +
|
||||
' • TypeScript source: query({ pathToQwenExecutable: "/path/to/index.ts" })\n' +
|
||||
' • Node.js bundle: query({ pathToQwenExecutable: "/path/to/cli.js" })\n' +
|
||||
' • Force specific runtime: query({ pathToQwenExecutable: "bun:/path/to/cli.js" })',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is available in the system PATH
|
||||
*
|
||||
* @param command - Command to check (e.g., 'bun', 'tsx', 'deno')
|
||||
* @returns true if command is available
|
||||
*/
|
||||
function isCommandAvailable(command: string): boolean {
|
||||
try {
|
||||
// Use 'which' on Unix-like systems, 'where' on Windows
|
||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
execSync(`${whichCommand} ${command}`, {
|
||||
stdio: 'ignore',
|
||||
timeout: 5000, // 5 second timeout
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a runtime is available on the system
|
||||
*
|
||||
* @param runtime - Runtime to validate (node, bun, tsx, deno)
|
||||
* @returns true if runtime is available
|
||||
*/
|
||||
function validateRuntimeAvailability(runtime: string): boolean {
|
||||
// Node.js is always available since we're running in Node.js
|
||||
if (runtime === 'node') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the runtime command is available in PATH
|
||||
return isCommandAvailable(runtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file extension matches expected runtime
|
||||
*
|
||||
* @param filePath - Path to the file
|
||||
* @param runtime - Expected runtime
|
||||
* @returns true if extension is compatible
|
||||
*/
|
||||
function validateFileExtensionForRuntime(
|
||||
filePath: string,
|
||||
runtime: string,
|
||||
): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
switch (runtime) {
|
||||
case 'node':
|
||||
case 'bun':
|
||||
return ['.js', '.mjs', '.cjs'].includes(ext);
|
||||
case 'tsx':
|
||||
return ['.ts', '.tsx'].includes(ext);
|
||||
case 'deno':
|
||||
return ['.ts', '.tsx', '.js', '.mjs'].includes(ext);
|
||||
default:
|
||||
return true; // Unknown runtime, let it pass
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse executable specification into components with comprehensive validation
|
||||
*
|
||||
* Supports multiple formats:
|
||||
* - 'qwen' -> native binary (auto-detected)
|
||||
* - '/path/to/qwen' -> native binary (explicit path)
|
||||
* - '/path/to/cli.js' -> Node.js bundle (default for .js files)
|
||||
* - '/path/to/index.ts' -> TypeScript source (requires tsx)
|
||||
*
|
||||
* Advanced runtime specification (for overriding defaults):
|
||||
* - 'bun:/path/to/cli.js' -> Force Bun runtime
|
||||
* - 'node:/path/to/cli.js' -> Force Node.js runtime
|
||||
* - 'tsx:/path/to/index.ts' -> Force tsx runtime
|
||||
* - 'deno:/path/to/cli.ts' -> Force Deno runtime
|
||||
*
|
||||
* @param executableSpec - Executable specification
|
||||
* @returns Parsed executable information
|
||||
* @throws Error if specification is invalid or files don't exist
|
||||
*/
|
||||
export function parseExecutableSpec(executableSpec?: string): {
|
||||
runtime?: string;
|
||||
executablePath: string;
|
||||
isExplicitRuntime: boolean;
|
||||
} {
|
||||
// Handle empty string case first (before checking for undefined/null)
|
||||
if (
|
||||
executableSpec === '' ||
|
||||
(executableSpec && executableSpec.trim() === '')
|
||||
) {
|
||||
throw new Error('Command name cannot be empty');
|
||||
}
|
||||
|
||||
if (!executableSpec) {
|
||||
// Auto-detect native CLI
|
||||
return {
|
||||
executablePath: findNativeCliPath(),
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for runtime prefix (e.g., 'bun:/path/to/cli.js')
|
||||
const runtimeMatch = executableSpec.match(/^([^:]+):(.+)$/);
|
||||
if (runtimeMatch) {
|
||||
const [, runtime, filePath] = runtimeMatch;
|
||||
if (!runtime || !filePath) {
|
||||
throw new Error(`Invalid runtime specification: '${executableSpec}'`);
|
||||
}
|
||||
|
||||
// Validate runtime is supported
|
||||
const supportedRuntimes = ['node', 'bun', 'tsx', 'deno'];
|
||||
if (!supportedRuntimes.includes(runtime)) {
|
||||
throw new Error(
|
||||
`Unsupported runtime '${runtime}'. Supported runtimes: ${supportedRuntimes.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate runtime availability
|
||||
if (!validateRuntimeAvailability(runtime)) {
|
||||
throw new Error(
|
||||
`Runtime '${runtime}' is not available on this system. Please install it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Validate file exists
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${resolvedPath}' for runtime '${runtime}'. ` +
|
||||
'Please check the file path and ensure the file exists.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file extension matches runtime
|
||||
if (!validateFileExtensionForRuntime(resolvedPath, runtime)) {
|
||||
const ext = path.extname(resolvedPath);
|
||||
throw new Error(
|
||||
`File extension '${ext}' is not compatible with runtime '${runtime}'. ` +
|
||||
`Expected extensions for ${runtime}: ${getExpectedExtensions(runtime).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
runtime,
|
||||
executablePath: resolvedPath,
|
||||
isExplicitRuntime: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a command name (no path separators) or a file path
|
||||
const isCommandName =
|
||||
!executableSpec.includes('/') && !executableSpec.includes('\\');
|
||||
|
||||
if (isCommandName) {
|
||||
// It's a command name like 'qwen' - validate it's a reasonable command name
|
||||
if (!executableSpec || executableSpec.trim() === '') {
|
||||
throw new Error('Command name cannot be empty');
|
||||
}
|
||||
|
||||
// Basic validation for command names
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(executableSpec)) {
|
||||
throw new Error(
|
||||
`Invalid command name '${executableSpec}'. Command names should only contain letters, numbers, dots, hyphens, and underscores.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: executableSpec,
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
// It's a file path - validate and resolve
|
||||
const resolvedPath = path.resolve(executableSpec);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(
|
||||
`Executable file not found at '${resolvedPath}'. ` +
|
||||
'Please check the file path and ensure the file exists. ' +
|
||||
'You can also:\n' +
|
||||
' • Set QWEN_CODE_CLI_PATH environment variable\n' +
|
||||
' • Install qwen globally: npm install -g qwen\n' +
|
||||
' • For TypeScript files, ensure tsx is installed: npm install -g tsx\n' +
|
||||
' • Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// Additional validation for file paths
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(
|
||||
`Path '${resolvedPath}' exists but is not a file. Please provide a path to an executable file.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: resolvedPath,
|
||||
isExplicitRuntime: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expected file extensions for a runtime
|
||||
*/
|
||||
function getExpectedExtensions(runtime: string): string[] {
|
||||
switch (runtime) {
|
||||
case 'node':
|
||||
case 'bun':
|
||||
return ['.js', '.mjs', '.cjs'];
|
||||
case 'tsx':
|
||||
return ['.ts', '.tsx'];
|
||||
case 'deno':
|
||||
return ['.ts', '.tsx', '.js', '.mjs'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve CLI path from options (backward compatibility)
|
||||
*
|
||||
* @param explicitPath - Optional explicit CLI path or command name
|
||||
* @returns Resolved CLI path
|
||||
* @throws Error if CLI not found
|
||||
* @deprecated Use parseExecutableSpec and prepareSpawnInfo instead
|
||||
*/
|
||||
export function resolveCliPath(explicitPath?: string): string {
|
||||
const parsed = parseExecutableSpec(explicitPath);
|
||||
return parsed.executablePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect runtime for file based on extension
|
||||
*
|
||||
* Uses sensible defaults:
|
||||
* - JavaScript files (.js, .mjs, .cjs) -> Node.js (default choice)
|
||||
* - TypeScript files (.ts, .tsx) -> tsx (if available)
|
||||
*
|
||||
* @param filePath - Path to the file
|
||||
* @returns Suggested runtime or undefined for native executables
|
||||
*/
|
||||
function detectRuntimeFromExtension(filePath: string): string | undefined {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (['.js', '.mjs', '.cjs'].includes(ext)) {
|
||||
// Default to Node.js for JavaScript files
|
||||
return 'node';
|
||||
}
|
||||
|
||||
if (['.ts', '.tsx'].includes(ext)) {
|
||||
// Check if tsx is available for TypeScript files
|
||||
if (isCommandAvailable('tsx')) {
|
||||
return 'tsx';
|
||||
}
|
||||
// If tsx is not available, suggest it in error message
|
||||
throw new Error(
|
||||
`TypeScript file '${filePath}' requires 'tsx' runtime, but it's not available. ` +
|
||||
'Please install tsx: npm install -g tsx, or use explicit runtime: tsx:/path/to/file.ts',
|
||||
);
|
||||
}
|
||||
|
||||
// Native executable or unknown extension
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare spawn information for CLI process
|
||||
*
|
||||
* Handles all supported executable formats with clear separation of concerns:
|
||||
* 1. Parse the executable specification
|
||||
* 2. Determine the appropriate runtime
|
||||
* 3. Build the spawn command and arguments
|
||||
*
|
||||
* @param executableSpec - Executable specification (path, command, or runtime:path)
|
||||
* @returns SpawnInfo with command and args for spawning
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Native binary (production)
|
||||
* prepareSpawnInfo('qwen') // -> { command: 'qwen', args: [], type: 'native' }
|
||||
*
|
||||
* // Node.js bundle (default for .js files)
|
||||
* prepareSpawnInfo('/path/to/cli.js') // -> { command: 'node', args: ['/path/to/cli.js'], type: 'node' }
|
||||
*
|
||||
* // TypeScript source (development, requires tsx)
|
||||
* prepareSpawnInfo('/path/to/index.ts') // -> { command: 'tsx', args: ['/path/to/index.ts'], type: 'tsx' }
|
||||
*
|
||||
* // Advanced: Force specific runtime
|
||||
* prepareSpawnInfo('bun:/path/to/cli.js') // -> { command: 'bun', args: ['/path/to/cli.js'], type: 'bun' }
|
||||
* ```
|
||||
*/
|
||||
export function prepareSpawnInfo(executableSpec?: string): SpawnInfo {
|
||||
const parsed = parseExecutableSpec(executableSpec);
|
||||
const { runtime, executablePath, isExplicitRuntime } = parsed;
|
||||
|
||||
// If runtime is explicitly specified, use it
|
||||
if (isExplicitRuntime && runtime) {
|
||||
const runtimeCommand = runtime === 'node' ? process.execPath : runtime;
|
||||
|
||||
return {
|
||||
command: runtimeCommand,
|
||||
args: [executablePath],
|
||||
type: runtime as ExecutableType,
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
// If no explicit runtime, try to detect from file extension
|
||||
const detectedRuntime = detectRuntimeFromExtension(executablePath);
|
||||
|
||||
if (detectedRuntime) {
|
||||
const runtimeCommand =
|
||||
detectedRuntime === 'node' ? process.execPath : detectedRuntime;
|
||||
|
||||
return {
|
||||
command: runtimeCommand,
|
||||
args: [executablePath],
|
||||
type: detectedRuntime as ExecutableType,
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Native executable or command name - use it directly
|
||||
return {
|
||||
command: executablePath,
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: executableSpec || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy function for backward compatibility
|
||||
* @deprecated Use prepareSpawnInfo() instead
|
||||
*/
|
||||
export function findCliPath(): string {
|
||||
return findNativeCliPath();
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* JSON Lines protocol utilities
|
||||
*
|
||||
* JSON Lines format: one JSON object per line, newline-delimited
|
||||
* Example:
|
||||
* {"type":"user","message":{...}}
|
||||
* {"type":"assistant","message":{...}}
|
||||
*
|
||||
* Used for SDK-CLI communication over stdin/stdout streams.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serialize a message to JSON Lines format
|
||||
*
|
||||
* Converts object to JSON and appends newline.
|
||||
*
|
||||
* @param message - Object to serialize
|
||||
* @returns JSON string with trailing newline
|
||||
* @throws Error if JSON serialization fails
|
||||
*/
|
||||
export function serializeJsonLine(message: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(message) + '\n';
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to serialize message to JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON Lines message
|
||||
*
|
||||
* Parses single line of JSON (without newline).
|
||||
*
|
||||
* @param line - JSON string (without trailing newline)
|
||||
* @returns Parsed object
|
||||
* @throws Error if JSON parsing fails
|
||||
*/
|
||||
export function parseJsonLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON line: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON Lines with error handling
|
||||
*
|
||||
* Attempts to parse JSON line, logs warning and returns null on failure.
|
||||
* Useful for robust parsing where malformed messages should be skipped.
|
||||
*
|
||||
* @param line - JSON string (without trailing newline)
|
||||
* @param context - Context string for error logging (e.g., 'Transport')
|
||||
* @returns Parsed object or null if parsing fails
|
||||
*/
|
||||
export function parseJsonLineSafe(
|
||||
line: string,
|
||||
context = 'JsonLines',
|
||||
): unknown | null {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[${context}] Failed to parse JSON line, skipping:`,
|
||||
line.substring(0, 100),
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate message has required type field
|
||||
*
|
||||
* Ensures message conforms to basic message protocol.
|
||||
*
|
||||
* @param message - Parsed message object
|
||||
* @returns true if valid, false otherwise
|
||||
*/
|
||||
export function isValidMessage(message: unknown): boolean {
|
||||
return (
|
||||
message !== null &&
|
||||
typeof message === 'object' &&
|
||||
'type' in message &&
|
||||
typeof (message as { type: unknown }).type === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async generator that yields parsed JSON Lines from async iterable of strings
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const lines = readline.createInterface({ input: stream });
|
||||
* for await (const message of parseJsonLinesStream(lines)) {
|
||||
* console.log(message);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param lines - AsyncIterable of line strings
|
||||
* @param context - Context string for error logging
|
||||
* @yields Parsed message objects (skips malformed lines)
|
||||
*/
|
||||
export async function* parseJsonLinesStream(
|
||||
lines: AsyncIterable<string>,
|
||||
context = 'JsonLines',
|
||||
): AsyncGenerator<unknown, void, unknown> {
|
||||
for await (const line of lines) {
|
||||
// Skip empty lines
|
||||
if (line.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse with error handling
|
||||
const message = parseJsonLineSafe(line, context);
|
||||
|
||||
// Skip malformed messages
|
||||
if (message === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate message structure
|
||||
if (!isValidMessage(message)) {
|
||||
console.warn(
|
||||
`[${context}] Invalid message structure (missing 'type' field), skipping:`,
|
||||
line.substring(0, 100),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield message;
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
/**
|
||||
* E2E tests based on abort-and-lifecycle.ts example
|
||||
* Tests AbortController integration and process lifecycle management
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
AbortError,
|
||||
isAbortError,
|
||||
isCLIAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
} from '../../src/index.js';
|
||||
|
||||
// Test configuration
|
||||
const TEST_CLI_PATH =
|
||||
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||
const TEST_TIMEOUT = 30000;
|
||||
|
||||
// Shared test options with permissionMode to allow all tools
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
describe('AbortController and Process Lifecycle (E2E)', () => {
|
||||
describe('Basic AbortController Usage', () => {
|
||||
/* TODO: Currently query does not throw AbortError when aborted */
|
||||
it(
|
||||
'should support AbortController cancellation',
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Abort after 5 seconds
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 5000);
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long story about TypeScript programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
|
||||
// Should receive some content before abort
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Should not reach here - query should be aborted
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle immediate abort',
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
console.log('Aborted!');
|
||||
}, 300);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Process Lifecycle Monitoring', () => {
|
||||
it(
|
||||
'should handle normal process completion',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedSuccessfully = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
completedSuccessfully = true;
|
||||
} catch (error) {
|
||||
// Should not throw for normal completion
|
||||
expect(false).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(completedSuccessfully).toBe(true);
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle process cleanup after error',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Expected to potentially have errors
|
||||
} finally {
|
||||
// Should cleanup successfully even after error
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Input Stream Control', () => {
|
||||
it(
|
||||
'should support endInput() method',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let receivedResponse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock =>
|
||||
block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b: TextBlock) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
receivedResponse = true;
|
||||
|
||||
// End input after receiving first response
|
||||
q.endInput();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(receivedResponse).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Error Handling and Recovery', () => {
|
||||
it(
|
||||
'should handle invalid executable path',
|
||||
async () => {
|
||||
try {
|
||||
const q = query({
|
||||
prompt: 'Hello world',
|
||||
options: {
|
||||
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not reach here - query() should throw immediately
|
||||
for await (const _message of q) {
|
||||
// Should not reach here
|
||||
}
|
||||
|
||||
// Should not reach here
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect((error as Error).message).toBeDefined();
|
||||
expect((error as Error).message).toContain(
|
||||
'Invalid pathToQwenExecutable',
|
||||
);
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle AbortError correctly',
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a long story',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort after short delay
|
||||
setTimeout(() => controller.abort(), 500);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Debugging with stderr callback', () => {
|
||||
it(
|
||||
'should capture stderr messages when debug is enabled',
|
||||
async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: true,
|
||||
stderr: (message: string) => {
|
||||
stderrMessages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should not capture stderr when debug is disabled',
|
||||
async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
stderr: (message: string) => {
|
||||
stderrMessages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
// Should have minimal or no stderr output when debug is false
|
||||
expect(stderrMessages.length).toBeLessThan(10);
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Abort with Cleanup', () => {
|
||||
it(
|
||||
'should cleanup properly after abort',
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay about programming',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// May receive some messages before abort
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AbortError) {
|
||||
expect(true).toBe(true); // Expected abort error
|
||||
} else {
|
||||
throw error; // Unexpected error
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed after abort
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle multiple abort calls gracefully',
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Count to 100',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Multiple abort calls
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
setTimeout(() => controller.abort(), 200);
|
||||
setTimeout(() => controller.abort(), 300);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Should be interrupted
|
||||
}
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Resource Management Edge Cases', () => {
|
||||
it(
|
||||
'should handle close() called multiple times',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start the query
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Close multiple times
|
||||
await q.close();
|
||||
await q.close();
|
||||
await q.close();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle abort after close',
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start and close immediately
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
await q.close();
|
||||
|
||||
// Abort after close
|
||||
controller.abort();
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,515 +0,0 @@
|
||||
/**
|
||||
* E2E tests based on basic-usage.ts example
|
||||
* Tests message type recognition and basic query patterns
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type CLIMessage,
|
||||
type ControlMessage,
|
||||
type CLISystemMessage,
|
||||
type CLIUserMessage,
|
||||
type CLIAssistantMessage,
|
||||
type ToolUseBlock,
|
||||
type ToolResultBlock,
|
||||
} from '../../src/types/protocol.js';
|
||||
|
||||
// Test configuration
|
||||
const TEST_CLI_PATH =
|
||||
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||
const TEST_TIMEOUT = 30000;
|
||||
|
||||
// Shared test options with permissionMode to allow all tools
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the message type using protocol type guards
|
||||
*/
|
||||
function getMessageType(message: CLIMessage | ControlMessage): string {
|
||||
if (isCLIUserMessage(message)) {
|
||||
return '🧑 USER';
|
||||
} else if (isCLIAssistantMessage(message)) {
|
||||
return '🤖 ASSISTANT';
|
||||
} else if (isCLISystemMessage(message)) {
|
||||
return `🖥️ SYSTEM(${message.subtype})`;
|
||||
} else if (isCLIResultMessage(message)) {
|
||||
return `✅ RESULT(${message.subtype})`;
|
||||
} else if (isCLIPartialAssistantMessage(message)) {
|
||||
return '⏳ STREAM_EVENT';
|
||||
} else if (isControlRequest(message)) {
|
||||
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
|
||||
} else if (isControlResponse(message)) {
|
||||
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
|
||||
} else if (isControlCancel(message)) {
|
||||
return '🛑 CONTROL_CANCEL';
|
||||
} else {
|
||||
return '❓ UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
describe('Basic Usage (E2E)', () => {
|
||||
describe('Message Type Recognition', () => {
|
||||
it('should correctly identify message types using type guards', async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'What files are in the current directory? List only the top-level files and folders.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
const messageTypes: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
const messageType = getMessageType(message);
|
||||
messageTypes.push(messageType);
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(messageTypes.length).toBe(messages.length);
|
||||
|
||||
// Should have at least assistant and result messages
|
||||
expect(messageTypes.some((type) => type.includes('ASSISTANT'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(messageTypes.some((type) => type.includes('RESULT'))).toBe(true);
|
||||
|
||||
// Verify type guards work correctly
|
||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||
const resultMessages = messages.filter(isCLIResultMessage);
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
expect(resultMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'should handle message content extraction',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Say hello and explain what you are',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
let assistantMessage: CLIAssistantMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessage = message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessage).not.toBeNull();
|
||||
expect(assistantMessage!.message.content).toBeDefined();
|
||||
|
||||
// Extract text blocks
|
||||
const textBlocks = assistantMessage!.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
|
||||
expect(textBlocks.length).toBeGreaterThan(0);
|
||||
expect(textBlocks[0].text).toBeDefined();
|
||||
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Basic Query Patterns', () => {
|
||||
it(
|
||||
'should handle simple question-answer pattern',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have assistant response
|
||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Should end with result
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
if (isCLIResultMessage(lastMessage)) {
|
||||
expect(lastMessage.subtype).toBe('success');
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle file system query pattern',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'What files are in the current directory? List only the top-level files and folders.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let hasToolUse = false;
|
||||
let hasToolResult = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const toolUseBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (toolUseBlock) {
|
||||
hasToolUse = true;
|
||||
expect(toolUseBlock.name).toBeDefined();
|
||||
expect(toolUseBlock.id).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
if (isCLIUserMessage(message)) {
|
||||
// Tool results are sent as user messages with ToolResultBlock[] content
|
||||
if (Array.isArray(message.message.content)) {
|
||||
const toolResultBlock = message.message.content.find(
|
||||
(block: ToolResultBlock): block is ToolResultBlock =>
|
||||
block.type === 'tool_result',
|
||||
);
|
||||
if (toolResultBlock) {
|
||||
hasToolResult = true;
|
||||
expect(toolResultBlock.tool_use_id).toBeDefined();
|
||||
expect(toolResultBlock.content).toBeDefined();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(hasToolUse).toBe(true);
|
||||
expect(hasToolResult).toBe(true);
|
||||
|
||||
// Should have assistant response after tool execution
|
||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Configuration and Options', () => {
|
||||
it(
|
||||
'should respect debug option',
|
||||
async () => {
|
||||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: true,
|
||||
stderr: (message: string) => {
|
||||
stderrMessages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Debug mode should produce stderr output
|
||||
expect(stderrMessages.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should respect cwd option',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'List files in current directory',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let hasResponse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
hasResponse = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasResponse).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('SDK-CLI Handshaking Process', () => {
|
||||
it(
|
||||
'should receive system message after initialization',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let systemMessage: CLISystemMessage | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
// Capture system message
|
||||
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
break; // Exit early once we get the system message
|
||||
}
|
||||
|
||||
// Stop after getting assistant response to avoid long execution
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify system message was received after initialization
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.type).toBe('system');
|
||||
expect(systemMessage!.subtype).toBe('init');
|
||||
|
||||
// Validate system message structure matches sendSystemMessage()
|
||||
expect(systemMessage!.uuid).toBeDefined();
|
||||
expect(systemMessage!.session_id).toBeDefined();
|
||||
expect(systemMessage!.cwd).toBeDefined();
|
||||
expect(systemMessage!.tools).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.tools)).toBe(true);
|
||||
expect(systemMessage!.mcp_servers).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.mcp_servers)).toBe(true);
|
||||
expect(systemMessage!.model).toBeDefined();
|
||||
expect(systemMessage!.permissionMode).toBeDefined();
|
||||
expect(systemMessage!.slash_commands).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.slash_commands)).toBe(true);
|
||||
expect(systemMessage!.apiKeySource).toBeDefined();
|
||||
expect(systemMessage!.qwen_code_version).toBeDefined();
|
||||
expect(systemMessage!.output_style).toBeDefined();
|
||||
expect(systemMessage!.agents).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.agents)).toBe(true);
|
||||
expect(systemMessage!.skills).toBeDefined();
|
||||
expect(Array.isArray(systemMessage!.skills)).toBe(true);
|
||||
|
||||
// Verify system message appears early in the message sequence
|
||||
const systemMessageIndex = messages.findIndex(
|
||||
(msg) => isCLISystemMessage(msg) && msg.subtype === 'init',
|
||||
);
|
||||
expect(systemMessageIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(systemMessageIndex).toBeLessThan(3); // Should be one of the first few messages
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle initialization with session ID consistency',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let systemMessage: CLISystemMessage | null = null;
|
||||
let userMessage: CLIUserMessage | null = null;
|
||||
const sessionId = q.getSessionId();
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
// Capture system message
|
||||
if (isCLISystemMessage(message) && message.subtype === 'init') {
|
||||
systemMessage = message;
|
||||
}
|
||||
|
||||
// Capture user message
|
||||
if (isCLIUserMessage(message)) {
|
||||
userMessage = message;
|
||||
}
|
||||
|
||||
// Stop after getting assistant response to avoid long execution
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify session IDs are consistent within the system
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(systemMessage).not.toBeNull();
|
||||
expect(systemMessage!.session_id).toBeDefined();
|
||||
expect(systemMessage!.uuid).toBeDefined();
|
||||
|
||||
// System message should have consistent session_id and uuid
|
||||
expect(systemMessage!.session_id).toBe(systemMessage!.uuid);
|
||||
|
||||
if (userMessage) {
|
||||
expect(userMessage.session_id).toBeDefined();
|
||||
// User message should have the same session_id as system message
|
||||
expect(userMessage.session_id).toBe(systemMessage!.session_id);
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Message Flow Validation', () => {
|
||||
it(
|
||||
'should follow expected message sequence',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'What is the current time?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messageSequence: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageSequence.push(message.type);
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageSequence.length).toBeGreaterThan(0);
|
||||
|
||||
// Should end with result
|
||||
expect(messageSequence[messageSequence.length - 1]).toBe('result');
|
||||
|
||||
// Should have at least one assistant message
|
||||
expect(messageSequence).toContain('assistant');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle graceful completion',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Say goodbye',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: true,
|
||||
},
|
||||
});
|
||||
|
||||
let completedNaturally = false;
|
||||
let messageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageCount++;
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
completedNaturally = true;
|
||||
expect(message.subtype).toBe('success');
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageCount).toBeGreaterThan(0);
|
||||
expect(completedNaturally).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,517 +0,0 @@
|
||||
/**
|
||||
* E2E tests based on multi-turn.ts example
|
||||
* Tests multi-turn conversation functionality with real CLI
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { query } from '../../src/index.js';
|
||||
import {
|
||||
isCLIUserMessage,
|
||||
isCLIAssistantMessage,
|
||||
isCLISystemMessage,
|
||||
isCLIResultMessage,
|
||||
isCLIPartialAssistantMessage,
|
||||
isControlRequest,
|
||||
isControlResponse,
|
||||
isControlCancel,
|
||||
type CLIUserMessage,
|
||||
type CLIAssistantMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type CLIMessage,
|
||||
type ControlMessage,
|
||||
type ToolUseBlock,
|
||||
} from '../../src/types/protocol.js';
|
||||
|
||||
// Test configuration
|
||||
const TEST_CLI_PATH =
|
||||
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||
const TEST_TIMEOUT = 60000; // Longer timeout for multi-turn conversations
|
||||
|
||||
// Shared test options with permissionMode to allow all tools
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the message type using protocol type guards
|
||||
*/
|
||||
function getMessageType(message: CLIMessage | ControlMessage): string {
|
||||
if (isCLIUserMessage(message)) {
|
||||
return '🧑 USER';
|
||||
} else if (isCLIAssistantMessage(message)) {
|
||||
return '🤖 ASSISTANT';
|
||||
} else if (isCLISystemMessage(message)) {
|
||||
return `🖥️ SYSTEM(${message.subtype})`;
|
||||
} else if (isCLIResultMessage(message)) {
|
||||
return `✅ RESULT(${message.subtype})`;
|
||||
} else if (isCLIPartialAssistantMessage(message)) {
|
||||
return '⏳ STREAM_EVENT';
|
||||
} else if (isControlRequest(message)) {
|
||||
return `🎮 CONTROL_REQUEST(${message.request.subtype})`;
|
||||
} else if (isControlResponse(message)) {
|
||||
return `📭 CONTROL_RESPONSE(${message.response.subtype})`;
|
||||
} else if (isControlCancel(message)) {
|
||||
return '🛑 CONTROL_CANCEL';
|
||||
} else {
|
||||
return '❓ UNKNOWN';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract text from ContentBlock array
|
||||
*/
|
||||
function extractText(content: ContentBlock[]): string {
|
||||
return content
|
||||
.filter((block): block is TextBlock => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('Multi-Turn Conversations (E2E)', () => {
|
||||
describe('AsyncIterable Prompt Support', () => {
|
||||
it(
|
||||
'should handle multi-turn conversation using AsyncIterable prompt',
|
||||
async () => {
|
||||
// Create multi-turn conversation generator
|
||||
async function* createMultiTurnConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'What is the name of this project? Check the package.json file.',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
// Wait a bit to simulate user thinking time
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What version is it currently on?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What are the main dependencies?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
// Create multi-turn query using AsyncIterable prompt
|
||||
const q = query({
|
||||
prompt: createMultiTurnConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
const assistantMessages: CLIAssistantMessage[] = [];
|
||||
let turnCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
turnCount++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(3); // Should have responses to all 3 questions
|
||||
expect(turnCount).toBeGreaterThanOrEqual(3);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should maintain session context across turns',
|
||||
async () => {
|
||||
async function* createContextualConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content:
|
||||
'My name is Alice. Remember this during our current conversation.',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'What is my name?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createContextualConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessages: CLIAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The second response should reference the name Alice
|
||||
const secondResponse = extractText(
|
||||
assistantMessages[1].message.content,
|
||||
);
|
||||
expect(secondResponse.toLowerCase()).toContain('alice');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Tool Usage in Multi-Turn', () => {
|
||||
it(
|
||||
'should handle tool usage across multiple turns',
|
||||
async () => {
|
||||
async function* createToolConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'List the files in the current directory',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Now tell me about the package.json file specifically',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createToolConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let toolUseCount = 0;
|
||||
let assistantCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const hasToolUseBlock = message.message.content.some(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (hasToolUseBlock) {
|
||||
toolUseCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantCount++;
|
||||
}
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(toolUseCount).toBeGreaterThan(0); // Should use tools
|
||||
expect(assistantCount).toBeGreaterThanOrEqual(2); // Should have responses to both questions
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Message Flow and Sequencing', () => {
|
||||
it(
|
||||
'should process messages in correct sequence',
|
||||
async () => {
|
||||
async function* createSequentialConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'First question: What is 1 + 1?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Second question: What is 2 + 2?',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createSequentialConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messageSequence: string[] = [];
|
||||
const assistantResponses: string[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
const messageType = getMessageType(message);
|
||||
messageSequence.push(messageType);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const text = extractText(message.message.content);
|
||||
assistantResponses.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageSequence.length).toBeGreaterThan(0);
|
||||
expect(assistantResponses.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Should end with result
|
||||
expect(messageSequence[messageSequence.length - 1]).toContain(
|
||||
'RESULT',
|
||||
);
|
||||
|
||||
// Should have assistant responses
|
||||
expect(
|
||||
messageSequence.some((type) => type.includes('ASSISTANT')),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle conversation completion correctly',
|
||||
async () => {
|
||||
async function* createSimpleConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Goodbye',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createSimpleConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let completedNaturally = false;
|
||||
let messageCount = 0;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageCount++;
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
completedNaturally = true;
|
||||
expect(message.subtype).toBe('success');
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageCount).toBeGreaterThan(0);
|
||||
expect(completedNaturally).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Error Handling in Multi-Turn', () => {
|
||||
it(
|
||||
'should handle empty conversation gracefully',
|
||||
async () => {
|
||||
async function* createEmptyConversation(): AsyncIterable<CLIUserMessage> {
|
||||
// Generator that yields nothing
|
||||
/* eslint-disable no-constant-condition */
|
||||
if (false) {
|
||||
yield {} as CLIUserMessage; // Unreachable, but satisfies TypeScript
|
||||
}
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createEmptyConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Should handle empty conversation without crashing
|
||||
expect(true).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle conversation with delays',
|
||||
async () => {
|
||||
async function* createDelayedConversation(): AsyncIterable<CLIUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'First message',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
|
||||
// Longer delay to test patience
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Second message after delay',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as CLIUserMessage;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createDelayedConversation(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessages: CLIAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,744 +0,0 @@
|
||||
/**
|
||||
* End-to-End tests for simple query execution with real CLI
|
||||
* Tests the complete SDK workflow with actual CLI subprocess
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
query,
|
||||
AbortError,
|
||||
isAbortError,
|
||||
isCLIAssistantMessage,
|
||||
isCLIUserMessage,
|
||||
isCLIResultMessage,
|
||||
type TextBlock,
|
||||
type ToolUseBlock,
|
||||
type ToolResultBlock,
|
||||
type ContentBlock,
|
||||
type CLIMessage,
|
||||
type CLIAssistantMessage,
|
||||
} from '../../src/index.js';
|
||||
|
||||
// Test configuration
|
||||
const TEST_CLI_PATH =
|
||||
'/Users/mingholy/Work/Projects/qwen-code/packages/cli/index.ts';
|
||||
const TEST_TIMEOUT = 30000;
|
||||
|
||||
// Shared test options with permissionMode to allow all tools
|
||||
const SHARED_TEST_OPTIONS = {
|
||||
pathToQwenExecutable: TEST_CLI_PATH,
|
||||
permissionMode: 'yolo' as const,
|
||||
};
|
||||
|
||||
describe('Simple Query Execution (E2E)', () => {
|
||||
describe('Basic Query Flow', () => {
|
||||
it(
|
||||
'should execute simple text query',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have at least one assistant message
|
||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Should end with result message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should receive assistant response',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let hasAssistantMessage = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
hasAssistantMessage = true;
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
expect(textBlocks.length).toBeGreaterThan(0);
|
||||
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasAssistantMessage).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should receive result message at end',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Simple test',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
expect(isCLIResultMessage(lastMessage)).toBe(true);
|
||||
if (isCLIResultMessage(lastMessage)) {
|
||||
expect(lastMessage.subtype).toBe('success');
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should complete iteration after result',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello, who are you?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let messageCount = 0;
|
||||
let completedNaturally = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messageCount++;
|
||||
if (isCLIResultMessage(message)) {
|
||||
// Should be the last message
|
||||
completedNaturally = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messageCount).toBeGreaterThan(0);
|
||||
expect(completedNaturally).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Query with Tool Usage', () => {
|
||||
it(
|
||||
'should handle query requiring tool execution',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'What files are in the current directory? List only the top-level files and folders.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
let hasToolUse = false;
|
||||
let hasAssistantResponse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
hasAssistantResponse = true;
|
||||
const hasToolUseBlock = message.message.content.some(
|
||||
(block: ContentBlock) => block.type === 'tool_use',
|
||||
);
|
||||
if (hasToolUseBlock) {
|
||||
hasToolUse = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(hasToolUse).toBe(true);
|
||||
expect(hasAssistantResponse).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should yield tool_use messages',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'List files in current directory',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let toolUseMessage: ToolUseBlock | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const toolUseBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolUseBlock =>
|
||||
block.type === 'tool_use',
|
||||
);
|
||||
if (toolUseBlock) {
|
||||
toolUseMessage = toolUseBlock;
|
||||
expect(toolUseBlock.name).toBeDefined();
|
||||
expect(toolUseBlock.id).toBeDefined();
|
||||
expect(toolUseBlock.input).toBeDefined();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(toolUseMessage).not.toBeNull();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should yield tool_result messages',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'List files in current directory',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let toolResultMessage: ToolResultBlock | null = null;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIUserMessage(message)) {
|
||||
// Tool results are sent as user messages with ToolResultBlock[] content
|
||||
if (Array.isArray(message.message.content)) {
|
||||
const toolResultBlock = message.message.content.find(
|
||||
(block: ContentBlock): block is ToolResultBlock =>
|
||||
block.type === 'tool_result',
|
||||
);
|
||||
if (toolResultBlock) {
|
||||
toolResultMessage = toolResultBlock;
|
||||
expect(toolResultBlock.tool_use_id).toBeDefined();
|
||||
expect(toolResultBlock.content).toBeDefined();
|
||||
// Content should not be a simple string but structured data
|
||||
expect(typeof toolResultBlock.content).not.toBe('undefined');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(toolResultMessage).not.toBeNull();
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should yield final assistant response',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'List files in current directory and tell me what you found',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const assistantMessages: CLIAssistantMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
assistantMessages.push(message);
|
||||
}
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
|
||||
// Final assistant message should contain summary
|
||||
const finalAssistant =
|
||||
assistantMessages[assistantMessages.length - 1];
|
||||
const textBlocks = finalAssistant.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
expect(textBlocks.length).toBeGreaterThan(0);
|
||||
expect(textBlocks[0].text.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Configuration Options', () => {
|
||||
it(
|
||||
'should respect cwd option',
|
||||
async () => {
|
||||
const testDir = '/tmp';
|
||||
|
||||
const q = query({
|
||||
prompt: 'What is the current working directory?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let hasResponse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
hasResponse = true;
|
||||
// Should execute in specified directory
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasResponse).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should use explicit CLI path when provided',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let hasResponse = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
hasResponse = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasResponse).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Resource Management', () => {
|
||||
it(
|
||||
'should cleanup subprocess on close()',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Start and immediately close
|
||||
const iterator = q[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
// Should close without error
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it(
|
||||
'should throw if CLI not found',
|
||||
async () => {
|
||||
try {
|
||||
const q = query({
|
||||
prompt: 'Hello',
|
||||
options: {
|
||||
pathToQwenExecutable: '/nonexistent/path/to/cli',
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not reach here - query() should throw immediately
|
||||
for await (const _message of q) {
|
||||
// Should not reach here
|
||||
}
|
||||
expect(false).toBe(true); // Should have thrown
|
||||
} catch (error) {
|
||||
expect(error).toBeDefined();
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect((error as Error).message).toContain(
|
||||
'Invalid pathToQwenExecutable',
|
||||
);
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Timeout and Cancellation', () => {
|
||||
it(
|
||||
'should support AbortSignal cancellation',
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// Abort after 2 seconds
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 2000);
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long story about TypeScript',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Should be interrupted by abort
|
||||
}
|
||||
|
||||
// Should not reach here
|
||||
expect(false).toBe(true);
|
||||
} catch (error) {
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should cleanup on cancellation',
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const q = query({
|
||||
prompt: 'Write a very long essay',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
abortController: controller,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Abort immediately
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
|
||||
try {
|
||||
for await (const _message of q) {
|
||||
// Should be interrupted
|
||||
}
|
||||
} catch (error) {
|
||||
expect(error instanceof AbortError).toBe(true);
|
||||
} finally {
|
||||
// Should cleanup successfully even after abort
|
||||
await q.close();
|
||||
expect(true).toBe(true); // Cleanup completed
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Message Collection Patterns', () => {
|
||||
it(
|
||||
'should collect all messages in array',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
expect(messages.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have various message types
|
||||
const messageTypes = messages.map((m) => m.type);
|
||||
expect(messageTypes).toContain('assistant');
|
||||
expect(messageTypes).toContain('result');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should extract final answer',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'What is the capital of France?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Get last assistant message content
|
||||
const assistantMessages = messages.filter(isCLIAssistantMessage);
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1];
|
||||
const textBlocks = lastAssistant.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
|
||||
expect(textBlocks.length).toBeGreaterThan(0);
|
||||
expect(textBlocks[0].text).toContain('Paris');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should track tool usage',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt: 'List files in current directory',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: CLIMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// Count tool_use blocks in assistant messages and tool_result blocks in user messages
|
||||
let toolUseCount = 0;
|
||||
let toolResultCount = 0;
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
message.message.content.forEach((block: ContentBlock) => {
|
||||
if (block.type === 'tool_use') {
|
||||
toolUseCount++;
|
||||
}
|
||||
});
|
||||
} else if (isCLIUserMessage(message)) {
|
||||
// Tool results are in user messages
|
||||
if (Array.isArray(message.message.content)) {
|
||||
message.message.content.forEach((block: ContentBlock) => {
|
||||
if (block.type === 'tool_result') {
|
||||
toolResultCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(toolUseCount).toBeGreaterThan(0);
|
||||
expect(toolResultCount).toBeGreaterThan(0);
|
||||
|
||||
// Each tool_use should have a corresponding tool_result
|
||||
expect(toolResultCount).toBeGreaterThanOrEqual(toolUseCount);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('Real-World Scenarios', () => {
|
||||
it(
|
||||
'should handle code analysis query',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'What is the main export of the package.json file in this directory?',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let hasAnalysis = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock =>
|
||||
block.type === 'text',
|
||||
);
|
||||
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
|
||||
hasAnalysis = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasAnalysis).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should handle multi-step query',
|
||||
async () => {
|
||||
const q = query({
|
||||
prompt:
|
||||
'List the files in this directory and tell me what type of project this is',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: process.cwd(),
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let hasToolUse = false;
|
||||
let hasAnalysis = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const hasToolUseBlock = message.message.content.some(
|
||||
(block: ContentBlock) => block.type === 'tool_use',
|
||||
);
|
||||
if (hasToolUseBlock) {
|
||||
hasToolUse = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCLIAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock =>
|
||||
block.type === 'text',
|
||||
);
|
||||
if (textBlocks.length > 0 && textBlocks[0].text.length > 0) {
|
||||
hasAnalysis = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCLIResultMessage(message)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(hasToolUse).toBe(true);
|
||||
expect(hasAnalysis).toBe(true);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Unit tests for ProcessTransport
|
||||
* Tests subprocess lifecycle management and IPC
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Note: This is a placeholder test file
|
||||
// ProcessTransport will be implemented in Phase 3 Implementation (T021)
|
||||
// These tests are written first following TDD approach
|
||||
|
||||
describe('ProcessTransport', () => {
|
||||
describe('Construction and Initialization', () => {
|
||||
it('should create transport with required options', () => {
|
||||
// Test will be implemented with actual ProcessTransport class
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should validate pathToQwenExecutable exists', () => {
|
||||
// Should throw if pathToQwenExecutable does not exist
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should build CLI arguments correctly', () => {
|
||||
// Should include --input-format stream-json --output-format stream-json
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle Management', () => {
|
||||
it('should spawn subprocess on start()', async () => {
|
||||
// Should call child_process.spawn
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should set isReady to true after successful start', async () => {
|
||||
// isReady should be true after start() completes
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should throw if subprocess fails to spawn', async () => {
|
||||
// Should throw Error if ENOENT or spawn fails
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should close subprocess gracefully with SIGTERM', async () => {
|
||||
// Should send SIGTERM first
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should force kill with SIGKILL after timeout', async () => {
|
||||
// Should send SIGKILL after 5s if process doesn\'t exit
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should be idempotent when calling close() multiple times', async () => {
|
||||
// Multiple close() calls should not error
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should wait for process exit in waitForExit()', async () => {
|
||||
// Should resolve when process exits
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Reading', () => {
|
||||
it('should read JSON Lines from stdout', async () => {
|
||||
// Should use readline to read lines and parse JSON
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should yield parsed messages via readMessages()', async () => {
|
||||
// Should yield messages as async generator
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should skip malformed JSON lines with warning', async () => {
|
||||
// Should log warning and continue on parse error
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should complete generator when process exits', async () => {
|
||||
// readMessages() should complete when stdout closes
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should set exitError on unexpected process crash', async () => {
|
||||
// exitError should be set if process crashes
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Writing', () => {
|
||||
it('should write JSON Lines to stdin', () => {
|
||||
// Should write JSON + newline to stdin
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should throw if writing before transport is ready', () => {
|
||||
// write() should throw if isReady is false
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should throw if writing to closed transport', () => {
|
||||
// write() should throw if transport is closed
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle process spawn errors', async () => {
|
||||
// Should throw descriptive error on spawn failure
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle process exit with non-zero code', async () => {
|
||||
// Should set exitError when process exits with error
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle write errors to closed stdin', () => {
|
||||
// Should throw if stdin is closed
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource Cleanup', () => {
|
||||
it('should register cleanup on parent process exit', () => {
|
||||
// Should register process.on(\'exit\') handler
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should kill subprocess on parent exit', () => {
|
||||
// Cleanup should kill child process
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should remove event listeners on close', async () => {
|
||||
// Should clean up all event listeners
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI Arguments', () => {
|
||||
it('should include --input-format stream-json', () => {
|
||||
// Args should always include input format flag
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should include --output-format stream-json', () => {
|
||||
// Args should always include output format flag
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should include --model if provided', () => {
|
||||
// Args should include model flag if specified
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should include --permission-mode if provided', () => {
|
||||
// Args should include permission mode flag if specified
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should include --mcp-server for external MCP servers', () => {
|
||||
// Args should include MCP server configs
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Working Directory', () => {
|
||||
it('should spawn process in specified cwd', async () => {
|
||||
// Should use cwd option for child_process.spawn
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should default to process.cwd() if not specified', async () => {
|
||||
// Should use current working directory by default
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Variables', () => {
|
||||
it('should pass environment variables to subprocess', async () => {
|
||||
// Should merge env with process.env
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should inherit parent env by default', async () => {
|
||||
// Should use process.env if no env option
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debug Mode', () => {
|
||||
it('should inherit stderr when debug is true', async () => {
|
||||
// Should set stderr: \'inherit\' if debug flag set
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should ignore stderr when debug is false', async () => {
|
||||
// Should set stderr: \'ignore\' if debug flag not set
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* Unit tests for Query class
|
||||
* Tests message routing, lifecycle, and orchestration
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Note: This is a placeholder test file
|
||||
// Query will be implemented in Phase 3 Implementation (T022)
|
||||
// These tests are written first following TDD approach
|
||||
|
||||
describe('Query', () => {
|
||||
describe('Construction and Initialization', () => {
|
||||
it('should create Query with transport and options', () => {
|
||||
// Should accept Transport and CreateQueryOptions
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should generate unique session ID', () => {
|
||||
// Each Query should have unique session_id
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should validate MCP server name conflicts', () => {
|
||||
// Should throw if mcpServers and sdkMcpServers have same keys
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should lazy initialize on first message consumption', async () => {
|
||||
// Should not call initialize() until messages are read
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Routing', () => {
|
||||
it('should route user messages to CLI', async () => {
|
||||
// Initial prompt should be sent as user message
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should route assistant messages to output stream', async () => {
|
||||
// Assistant messages from CLI should be yielded to user
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should route tool_use messages to output stream', async () => {
|
||||
// Tool use messages should be yielded to user
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should route tool_result messages to output stream', async () => {
|
||||
// Tool result messages should be yielded to user
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should route result messages to output stream', async () => {
|
||||
// Result messages should be yielded to user
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should filter keep_alive messages from output', async () => {
|
||||
// Keep alive messages should not be yielded to user
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Control Plane - Permission Control', () => {
|
||||
it('should handle can_use_tool control requests', async () => {
|
||||
// Should invoke canUseTool callback
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should send control response with permission result', async () => {
|
||||
// Should send response with allowed: true/false
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should default to allowing tools if no callback', async () => {
|
||||
// If canUseTool not provided, should allow all
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle permission callback timeout', async () => {
|
||||
// Should deny permission if callback exceeds 30s
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle permission callback errors', async () => {
|
||||
// Should deny permission if callback throws
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Control Plane - MCP Messages', () => {
|
||||
it('should route MCP messages to SDK-embedded servers', async () => {
|
||||
// Should find SdkControlServerTransport by server name
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle MCP message responses', async () => {
|
||||
// Should send response back to CLI
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle MCP message timeout', async () => {
|
||||
// Should return error if MCP server doesn\'t respond in 30s
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle unknown MCP server names', async () => {
|
||||
// Should return error if server name not found
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Control Plane - Other Requests', () => {
|
||||
it('should handle initialize control request', async () => {
|
||||
// Should register SDK MCP servers with CLI
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle interrupt control request', async () => {
|
||||
// Should send interrupt message to CLI
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle set_permission_mode control request', async () => {
|
||||
// Should send permission mode update to CLI
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle supported_commands control request', async () => {
|
||||
// Should query CLI capabilities
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle mcp_server_status control request', async () => {
|
||||
// Should check MCP server health
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-Turn Conversation', () => {
|
||||
it('should support streamInput() for follow-up messages', async () => {
|
||||
// Should accept async iterable of messages
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should maintain session context across turns', async () => {
|
||||
// All messages should have same session_id
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should throw if streamInput() called on closed query', async () => {
|
||||
// Should throw Error if query is closed
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle Management', () => {
|
||||
it('should close transport on close()', async () => {
|
||||
// Should call transport.close()
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should mark query as closed', async () => {
|
||||
// closed flag should be true after close()
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should complete output stream on close()', async () => {
|
||||
// inputStream should be marked done
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should be idempotent when closing multiple times', async () => {
|
||||
// Multiple close() calls should not error
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should cleanup MCP transports on close()', async () => {
|
||||
// Should close all SdkControlServerTransport instances
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle abort signal cancellation', async () => {
|
||||
// Should abort on AbortSignal
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Async Iteration', () => {
|
||||
it('should support for await loop', async () => {
|
||||
// Should implement AsyncIterator protocol
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should yield messages in order', async () => {
|
||||
// Messages should be yielded in received order
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should complete iteration when query closes', async () => {
|
||||
// for await loop should exit when query closes
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should propagate transport errors', async () => {
|
||||
// Should throw if transport encounters error
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public API Methods', () => {
|
||||
it('should provide interrupt() method', async () => {
|
||||
// Should send interrupt control request
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should provide setPermissionMode() method', async () => {
|
||||
// Should send set_permission_mode control request
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should provide supportedCommands() method', async () => {
|
||||
// Should query CLI capabilities
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should provide mcpServerStatus() method', async () => {
|
||||
// Should check MCP server health
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should throw if methods called on closed query', async () => {
|
||||
// Public methods should throw if query is closed
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should propagate transport errors to stream', async () => {
|
||||
// Transport errors should be surfaced in for await loop
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle control request timeout', async () => {
|
||||
// Should return error if control request doesn\'t respond
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle malformed control responses', async () => {
|
||||
// Should handle invalid response structures
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should handle CLI sending error message', async () => {
|
||||
// Should yield error message to user
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should track pending control requests', () => {
|
||||
// Should maintain map of request_id -> Promise
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should track SDK MCP transports', () => {
|
||||
// Should maintain map of server_name -> SdkControlServerTransport
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should track initialization state', () => {
|
||||
// Should have initialized Promise
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
|
||||
it('should track closed state', () => {
|
||||
// Should have closed boolean flag
|
||||
expect(true).toBe(true); // Placeholder
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* Unit tests for SdkControlServerTransport
|
||||
*
|
||||
* Tests MCP message proxying between MCP Server and Query's control plane.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SdkControlServerTransport } from '../../src/mcp/SdkControlServerTransport.js';
|
||||
|
||||
describe('SdkControlServerTransport', () => {
|
||||
let sendToQuery: ReturnType<typeof vi.fn>;
|
||||
let transport: SdkControlServerTransport;
|
||||
|
||||
beforeEach(() => {
|
||||
sendToQuery = vi.fn().mockResolvedValue({ result: 'success' });
|
||||
transport = new SdkControlServerTransport({
|
||||
serverName: 'test-server',
|
||||
sendToQuery,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('should start successfully', async () => {
|
||||
await transport.start();
|
||||
expect(transport.isStarted()).toBe(true);
|
||||
});
|
||||
|
||||
it('should close successfully', async () => {
|
||||
await transport.start();
|
||||
await transport.close();
|
||||
expect(transport.isStarted()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle close callback', async () => {
|
||||
const onclose = vi.fn();
|
||||
transport.onclose = onclose;
|
||||
|
||||
await transport.start();
|
||||
await transport.close();
|
||||
|
||||
expect(onclose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Sending', () => {
|
||||
it('should send message to Query', async () => {
|
||||
await transport.start();
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
};
|
||||
|
||||
await transport.send(message);
|
||||
|
||||
expect(sendToQuery).toHaveBeenCalledWith(message);
|
||||
});
|
||||
|
||||
it('should throw error when sending before start', async () => {
|
||||
const message = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
};
|
||||
|
||||
await expect(transport.send(message)).rejects.toThrow('not started');
|
||||
});
|
||||
|
||||
it('should handle send errors', async () => {
|
||||
const error = new Error('Network error');
|
||||
sendToQuery.mockRejectedValue(error);
|
||||
|
||||
const onerror = vi.fn();
|
||||
transport.onerror = onerror;
|
||||
|
||||
await transport.start();
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
};
|
||||
|
||||
await expect(transport.send(message)).rejects.toThrow('Network error');
|
||||
expect(onerror).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Receiving', () => {
|
||||
it('should deliver message to MCP Server via onmessage', async () => {
|
||||
const onmessage = vi.fn();
|
||||
transport.onmessage = onmessage;
|
||||
|
||||
await transport.start();
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
result: { tools: [] },
|
||||
};
|
||||
|
||||
transport.handleMessage(message);
|
||||
|
||||
expect(onmessage).toHaveBeenCalledWith(message);
|
||||
});
|
||||
|
||||
it('should warn when receiving message without onmessage handler', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await transport.start();
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
result: {},
|
||||
};
|
||||
|
||||
transport.handleMessage(message);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should warn when receiving message for closed transport', async () => {
|
||||
const consoleWarnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
const onmessage = vi.fn();
|
||||
transport.onmessage = onmessage;
|
||||
|
||||
await transport.start();
|
||||
await transport.close();
|
||||
|
||||
const message = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
result: {},
|
||||
};
|
||||
|
||||
transport.handleMessage(message);
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
expect(onmessage).not.toHaveBeenCalled();
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should deliver error to MCP Server via onerror', async () => {
|
||||
const onerror = vi.fn();
|
||||
transport.onerror = onerror;
|
||||
|
||||
await transport.start();
|
||||
|
||||
const error = new Error('Test error');
|
||||
transport.handleError(error);
|
||||
|
||||
expect(onerror).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should log error when no onerror handler set', async () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await transport.start();
|
||||
|
||||
const error = new Error('Test error');
|
||||
transport.handleError(error);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server Name', () => {
|
||||
it('should return server name', () => {
|
||||
expect(transport.getServerName()).toBe('test-server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bidirectional Communication', () => {
|
||||
it('should support full message round-trip', async () => {
|
||||
const onmessage = vi.fn();
|
||||
transport.onmessage = onmessage;
|
||||
|
||||
await transport.start();
|
||||
|
||||
// Send request from MCP Server to CLI
|
||||
const request = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
method: 'tools/list',
|
||||
params: {},
|
||||
};
|
||||
|
||||
await transport.send(request);
|
||||
expect(sendToQuery).toHaveBeenCalledWith(request);
|
||||
|
||||
// Receive response from CLI to MCP Server
|
||||
const response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
result: {
|
||||
tools: [
|
||||
{
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
transport.handleMessage(response);
|
||||
expect(onmessage).toHaveBeenCalledWith(response);
|
||||
});
|
||||
|
||||
it('should handle multiple messages in sequence', async () => {
|
||||
const onmessage = vi.fn();
|
||||
transport.onmessage = onmessage;
|
||||
|
||||
await transport.start();
|
||||
|
||||
// Send multiple requests
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const message = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: i,
|
||||
method: 'test',
|
||||
};
|
||||
|
||||
await transport.send(message);
|
||||
}
|
||||
|
||||
expect(sendToQuery).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Receive multiple responses
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const message = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: i,
|
||||
result: {},
|
||||
};
|
||||
|
||||
transport.handleMessage(message);
|
||||
}
|
||||
|
||||
expect(onmessage).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,247 +0,0 @@
|
||||
/**
|
||||
* Unit tests for Stream class
|
||||
* Tests producer-consumer patterns and async iteration
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Stream } from '../../src/utils/Stream.js';
|
||||
|
||||
describe('Stream', () => {
|
||||
let stream: Stream<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
stream = new Stream<string>();
|
||||
});
|
||||
|
||||
describe('Producer-Consumer Patterns', () => {
|
||||
it('should deliver enqueued value immediately to waiting consumer', async () => {
|
||||
// Start consumer (waits for value)
|
||||
const consumerPromise = stream.next();
|
||||
|
||||
// Producer enqueues value
|
||||
stream.enqueue('hello');
|
||||
|
||||
// Consumer should receive value immediately
|
||||
const result = await consumerPromise;
|
||||
expect(result).toEqual({ value: 'hello', done: false });
|
||||
});
|
||||
|
||||
it('should buffer values when consumer is slow', async () => {
|
||||
// Producer enqueues multiple values
|
||||
stream.enqueue('first');
|
||||
stream.enqueue('second');
|
||||
stream.enqueue('third');
|
||||
|
||||
// Consumer reads buffered values
|
||||
expect(await stream.next()).toEqual({ value: 'first', done: false });
|
||||
expect(await stream.next()).toEqual({ value: 'second', done: false });
|
||||
expect(await stream.next()).toEqual({ value: 'third', done: false });
|
||||
});
|
||||
|
||||
it('should handle fast producer and fast consumer', async () => {
|
||||
const values: string[] = [];
|
||||
|
||||
// Produce and consume simultaneously
|
||||
const consumerPromise = (async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = await stream.next();
|
||||
if (!result.done) {
|
||||
values.push(result.value);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
stream.enqueue('a');
|
||||
stream.enqueue('b');
|
||||
stream.enqueue('c');
|
||||
|
||||
await consumerPromise;
|
||||
expect(values).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should handle async iteration with for await loop', async () => {
|
||||
const values: string[] = [];
|
||||
|
||||
// Start consumer
|
||||
const consumerPromise = (async () => {
|
||||
for await (const value of stream) {
|
||||
values.push(value);
|
||||
}
|
||||
})();
|
||||
|
||||
// Producer enqueues and completes
|
||||
stream.enqueue('x');
|
||||
stream.enqueue('y');
|
||||
stream.enqueue('z');
|
||||
stream.done();
|
||||
|
||||
await consumerPromise;
|
||||
expect(values).toEqual(['x', 'y', 'z']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stream Completion', () => {
|
||||
it('should signal completion when done() is called', async () => {
|
||||
stream.done();
|
||||
const result = await stream.next();
|
||||
expect(result).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it('should complete waiting consumer immediately', async () => {
|
||||
const consumerPromise = stream.next();
|
||||
stream.done();
|
||||
const result = await consumerPromise;
|
||||
expect(result).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it('should allow done() to be called multiple times (idempotent)', async () => {
|
||||
stream.done();
|
||||
stream.done();
|
||||
stream.done();
|
||||
|
||||
const result = await stream.next();
|
||||
expect(result).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it('should throw when enqueuing to completed stream', () => {
|
||||
stream.done();
|
||||
expect(() => stream.enqueue('value')).toThrow(
|
||||
'Cannot enqueue to completed stream',
|
||||
);
|
||||
});
|
||||
|
||||
it('should deliver buffered values before completion', async () => {
|
||||
stream.enqueue('first');
|
||||
stream.enqueue('second');
|
||||
stream.done();
|
||||
|
||||
expect(await stream.next()).toEqual({ value: 'first', done: false });
|
||||
expect(await stream.next()).toEqual({ value: 'second', done: false });
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should propagate error to waiting consumer', async () => {
|
||||
const consumerPromise = stream.next();
|
||||
const error = new Error('Stream error');
|
||||
stream.setError(error);
|
||||
|
||||
await expect(consumerPromise).rejects.toThrow('Stream error');
|
||||
});
|
||||
|
||||
it('should throw error on next read after error is set', async () => {
|
||||
const error = new Error('Test error');
|
||||
stream.setError(error);
|
||||
|
||||
await expect(stream.next()).rejects.toThrow('Test error');
|
||||
});
|
||||
|
||||
it('should throw when enqueuing to stream with error', () => {
|
||||
stream.setError(new Error('Error'));
|
||||
expect(() => stream.enqueue('value')).toThrow(
|
||||
'Cannot enqueue to stream with error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should only store first error (idempotent)', async () => {
|
||||
const firstError = new Error('First');
|
||||
const secondError = new Error('Second');
|
||||
|
||||
stream.setError(firstError);
|
||||
stream.setError(secondError);
|
||||
|
||||
await expect(stream.next()).rejects.toThrow('First');
|
||||
});
|
||||
|
||||
it('should deliver buffered values before throwing error', async () => {
|
||||
stream.enqueue('buffered');
|
||||
stream.setError(new Error('Stream error'));
|
||||
|
||||
expect(await stream.next()).toEqual({ value: 'buffered', done: false });
|
||||
await expect(stream.next()).rejects.toThrow('Stream error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Properties', () => {
|
||||
it('should track queue size correctly', () => {
|
||||
expect(stream.queueSize).toBe(0);
|
||||
|
||||
stream.enqueue('a');
|
||||
expect(stream.queueSize).toBe(1);
|
||||
|
||||
stream.enqueue('b');
|
||||
expect(stream.queueSize).toBe(2);
|
||||
});
|
||||
|
||||
it('should track completion state', () => {
|
||||
expect(stream.isComplete).toBe(false);
|
||||
stream.done();
|
||||
expect(stream.isComplete).toBe(true);
|
||||
});
|
||||
|
||||
it('should track error state', () => {
|
||||
expect(stream.hasError).toBe(false);
|
||||
stream.setError(new Error('Test'));
|
||||
expect(stream.hasError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty stream', async () => {
|
||||
stream.done();
|
||||
const result = await stream.next();
|
||||
expect(result.done).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle single value', async () => {
|
||||
stream.enqueue('only');
|
||||
stream.done();
|
||||
|
||||
expect(await stream.next()).toEqual({ value: 'only', done: false });
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
|
||||
it('should handle rapid enqueue-dequeue cycles', async () => {
|
||||
const iterations = 100;
|
||||
const values: number[] = [];
|
||||
|
||||
const producer = async (): Promise<void> => {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
stream.enqueue(i);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
stream.done();
|
||||
};
|
||||
|
||||
const consumer = async (): Promise<void> => {
|
||||
for await (const value of stream) {
|
||||
values.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([producer(), consumer()]);
|
||||
expect(values).toHaveLength(iterations);
|
||||
expect(values[0]).toBe(0);
|
||||
expect(values[iterations - 1]).toBe(iterations - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypeScript Types', () => {
|
||||
it('should handle different value types', async () => {
|
||||
const numberStream = new Stream<number>();
|
||||
numberStream.enqueue(42);
|
||||
numberStream.done();
|
||||
|
||||
const result = await numberStream.next();
|
||||
expect(result.value).toBe(42);
|
||||
|
||||
const objectStream = new Stream<{ id: number; name: string }>();
|
||||
objectStream.enqueue({ id: 1, name: 'test' });
|
||||
objectStream.done();
|
||||
|
||||
const objectResult = await objectStream.next();
|
||||
expect(objectResult.value).toEqual({ id: 1, name: 'test' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,668 +0,0 @@
|
||||
/**
|
||||
* Unit tests for CLI path utilities
|
||||
* Tests executable detection, parsing, and spawn info preparation
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
parseExecutableSpec,
|
||||
prepareSpawnInfo,
|
||||
findNativeCliPath,
|
||||
resolveCliPath,
|
||||
} from '../../src/utils/cliPath.js';
|
||||
|
||||
// Mock fs module
|
||||
vi.mock('node:fs');
|
||||
const mockFs = vi.mocked(fs);
|
||||
|
||||
// Mock child_process module
|
||||
vi.mock('node:child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
|
||||
// Mock process.versions for bun detection
|
||||
const originalVersions = process.versions;
|
||||
|
||||
describe('CLI Path Utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset process.versions
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: { ...originalVersions },
|
||||
writable: true,
|
||||
});
|
||||
// Default: tsx is available (can be overridden in specific tests)
|
||||
mockExecSync.mockReturnValue(Buffer.from(''));
|
||||
// Default: mock statSync to return a proper file stat object
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original process.versions
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: originalVersions,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExecutableSpec', () => {
|
||||
describe('auto-detection (no spec provided)', () => {
|
||||
it('should auto-detect native CLI when no spec provided', () => {
|
||||
// Mock environment variable
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec();
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: '/usr/local/bin/qwen',
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
|
||||
// Restore env
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
|
||||
it('should throw when auto-detection fails', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec()).toThrow(
|
||||
'qwen CLI not found. Please:',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime prefix parsing', () => {
|
||||
it('should parse node runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('node:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'node',
|
||||
executablePath: path.resolve('/path/to/cli.js'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse bun runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('bun:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'bun',
|
||||
executablePath: path.resolve('/path/to/cli.js'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse tsx runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('tsx:/path/to/index.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'tsx',
|
||||
executablePath: path.resolve('/path/to/index.ts'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse deno runtime prefix', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('deno:/path/to/cli.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'deno',
|
||||
executablePath: path.resolve('/path/to/cli.ts'),
|
||||
isExplicitRuntime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw for invalid runtime prefix format', () => {
|
||||
expect(() => parseExecutableSpec('invalid:format')).toThrow(
|
||||
'Unsupported runtime',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when runtime-prefixed file does not exist', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/nonexistent/cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('command name detection', () => {
|
||||
it('should detect command names without path separators', () => {
|
||||
const result = parseExecutableSpec('qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: 'qwen',
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect command names on Windows', () => {
|
||||
const result = parseExecutableSpec('qwen.exe');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: 'qwen.exe',
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path resolution', () => {
|
||||
it('should resolve absolute file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('/absolute/path/to/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: '/absolute/path/to/qwen',
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve relative file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = parseExecutableSpec('./relative/path/to/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
executablePath: path.resolve('./relative/path/to/qwen'),
|
||||
isExplicitRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when file path does not exist', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareSpawnInfo', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('native executables', () => {
|
||||
it('should prepare spawn info for native binary command', () => {
|
||||
const result = prepareSpawnInfo('qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
});
|
||||
|
||||
it('should prepare spawn info for native binary path', () => {
|
||||
const result = prepareSpawnInfo('/usr/local/bin/qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: '/usr/local/bin/qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: '/usr/local/bin/qwen',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('JavaScript files', () => {
|
||||
it('should use node for .js files', () => {
|
||||
const result = prepareSpawnInfo('/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'node',
|
||||
originalInput: '/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to node for .js files (not auto-detect bun)', () => {
|
||||
// Even when running under bun, default to node for .js files
|
||||
Object.defineProperty(process, 'versions', {
|
||||
value: { ...originalVersions, bun: '1.0.0' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const result = prepareSpawnInfo('/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'node',
|
||||
originalInput: '/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle .mjs files', () => {
|
||||
const result = prepareSpawnInfo('/path/to/cli.mjs');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.mjs')],
|
||||
type: 'node',
|
||||
originalInput: '/path/to/cli.mjs',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle .cjs files', () => {
|
||||
const result = prepareSpawnInfo('/path/to/cli.cjs');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.cjs')],
|
||||
type: 'node',
|
||||
originalInput: '/path/to/cli.cjs',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypeScript files', () => {
|
||||
it('should use tsx for .ts files when tsx is available', () => {
|
||||
// tsx is available by default in beforeEach
|
||||
const result = prepareSpawnInfo('/path/to/index.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve('/path/to/index.ts')],
|
||||
type: 'tsx',
|
||||
originalInput: '/path/to/index.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use tsx for .tsx files when tsx is available', () => {
|
||||
const result = prepareSpawnInfo('/path/to/cli.tsx');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve('/path/to/cli.tsx')],
|
||||
type: 'tsx',
|
||||
originalInput: '/path/to/cli.tsx',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw helpful error when tsx is not available', () => {
|
||||
// Mock tsx not being available
|
||||
mockExecSync.mockImplementation(() => {
|
||||
throw new Error('Command not found');
|
||||
});
|
||||
|
||||
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
|
||||
"TypeScript file '/path/to/index.ts' requires 'tsx' runtime, but it's not available",
|
||||
);
|
||||
expect(() => prepareSpawnInfo('/path/to/index.ts')).toThrow(
|
||||
'Please install tsx: npm install -g tsx',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit runtime specifications', () => {
|
||||
it('should use explicit node runtime', () => {
|
||||
const result = prepareSpawnInfo('node:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'node',
|
||||
originalInput: 'node:/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit bun runtime', () => {
|
||||
const result = prepareSpawnInfo('bun:/path/to/cli.js');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'bun',
|
||||
args: [path.resolve('/path/to/cli.js')],
|
||||
type: 'bun',
|
||||
originalInput: 'bun:/path/to/cli.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit tsx runtime', () => {
|
||||
const result = prepareSpawnInfo('tsx:/path/to/index.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve('/path/to/index.ts')],
|
||||
type: 'tsx',
|
||||
originalInput: 'tsx:/path/to/index.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use explicit deno runtime', () => {
|
||||
const result = prepareSpawnInfo('deno:/path/to/cli.ts');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'deno',
|
||||
args: [path.resolve('/path/to/cli.ts')],
|
||||
type: 'deno',
|
||||
originalInput: 'deno:/path/to/cli.ts',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-detection fallback', () => {
|
||||
it('should auto-detect when no spec provided', () => {
|
||||
// Mock environment variable
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
||||
|
||||
const result = prepareSpawnInfo();
|
||||
|
||||
expect(result).toEqual({
|
||||
command: '/usr/local/bin/qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: '',
|
||||
});
|
||||
|
||||
// Restore env
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNativeCliPath', () => {
|
||||
it('should find CLI from environment variable', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/custom/path/to/qwen';
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = findNativeCliPath();
|
||||
|
||||
expect(result).toBe('/custom/path/to/qwen');
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
|
||||
it('should search common installation locations', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
delete process.env['QWEN_CODE_CLI_PATH'];
|
||||
|
||||
// Mock fs.existsSync to return true for volta bin
|
||||
mockFs.existsSync.mockImplementation((path) => {
|
||||
return path.toString().includes('.volta/bin/qwen');
|
||||
});
|
||||
|
||||
const result = findNativeCliPath();
|
||||
|
||||
expect(result).toContain('.volta/bin/qwen');
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
|
||||
it('should throw descriptive error when CLI not found', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
delete process.env['QWEN_CODE_CLI_PATH'];
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => findNativeCliPath()).toThrow('qwen CLI not found. Please:');
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCliPath (backward compatibility)', () => {
|
||||
it('should resolve CLI path for backward compatibility', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = resolveCliPath('/path/to/qwen');
|
||||
|
||||
expect(result).toBe('/path/to/qwen');
|
||||
});
|
||||
|
||||
it('should auto-detect when no path provided', () => {
|
||||
const originalEnv = process.env['QWEN_CODE_CLI_PATH'];
|
||||
process.env['QWEN_CODE_CLI_PATH'] = '/usr/local/bin/qwen';
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
const result = resolveCliPath();
|
||||
|
||||
expect(result).toBe('/usr/local/bin/qwen');
|
||||
|
||||
process.env['QWEN_CODE_CLI_PATH'] = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world use cases', () => {
|
||||
beforeEach(() => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should handle development with TypeScript source', () => {
|
||||
const devPath = '/Users/dev/qwen-code/packages/cli/index.ts';
|
||||
const result = prepareSpawnInfo(devPath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'tsx',
|
||||
args: [path.resolve(devPath)],
|
||||
type: 'tsx',
|
||||
originalInput: devPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle production bundle validation', () => {
|
||||
const bundlePath = '/path/to/bundled/cli.js';
|
||||
const result = prepareSpawnInfo(bundlePath);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: process.execPath,
|
||||
args: [path.resolve(bundlePath)],
|
||||
type: 'node',
|
||||
originalInput: bundlePath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle production native binary', () => {
|
||||
const result = prepareSpawnInfo('qwen');
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
type: 'native',
|
||||
originalInput: 'qwen',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle bun runtime with bundle', () => {
|
||||
const bundlePath = '/path/to/cli.js';
|
||||
const result = prepareSpawnInfo(`bun:${bundlePath}`);
|
||||
|
||||
expect(result).toEqual({
|
||||
command: 'bun',
|
||||
args: [path.resolve(bundlePath)],
|
||||
type: 'bun',
|
||||
originalInput: `bun:${bundlePath}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('should provide helpful error for missing TypeScript file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/missing/index.ts')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for missing JavaScript file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => prepareSpawnInfo('/missing/cli.js')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for invalid runtime specification', () => {
|
||||
expect(() => prepareSpawnInfo('invalid:spec')).toThrow(
|
||||
'Unsupported runtime',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comprehensive validation', () => {
|
||||
describe('runtime validation', () => {
|
||||
it('should reject unsupported runtimes', () => {
|
||||
expect(() =>
|
||||
parseExecutableSpec('unsupported:/path/to/file.js'),
|
||||
).toThrow(
|
||||
"Unsupported runtime 'unsupported'. Supported runtimes: node, bun, tsx, deno",
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate runtime availability for explicit runtime specs', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
// Mock bun not being available
|
||||
mockExecSync.mockImplementation((command) => {
|
||||
if (command.includes('bun')) {
|
||||
throw new Error('Command not found');
|
||||
}
|
||||
return Buffer.from('');
|
||||
});
|
||||
|
||||
expect(() => parseExecutableSpec('bun:/path/to/cli.js')).toThrow(
|
||||
"Runtime 'bun' is not available on this system. Please install it first.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow node runtime (always available)', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/path/to/cli.js')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should validate file extension matches runtime', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/path/to/file.js')).toThrow(
|
||||
"File extension '.js' is not compatible with runtime 'tsx'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate node runtime with JavaScript files', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('node:/path/to/file.ts')).toThrow(
|
||||
"File extension '.ts' is not compatible with runtime 'node'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid runtime-file combinations', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/path/to/file.ts')).not.toThrow();
|
||||
expect(() =>
|
||||
parseExecutableSpec('node:/path/to/file.js'),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
parseExecutableSpec('bun:/path/to/file.mjs'),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('command name validation', () => {
|
||||
it('should reject empty command names', () => {
|
||||
expect(() => parseExecutableSpec('')).toThrow(
|
||||
'Command name cannot be empty',
|
||||
);
|
||||
expect(() => parseExecutableSpec(' ')).toThrow(
|
||||
'Command name cannot be empty',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid command name characters', () => {
|
||||
expect(() => parseExecutableSpec('qwen@invalid')).toThrow(
|
||||
"Invalid command name 'qwen@invalid'. Command names should only contain letters, numbers, dots, hyphens, and underscores.",
|
||||
);
|
||||
|
||||
expect(() => parseExecutableSpec('qwen/invalid')).not.toThrow(); // This is treated as a path
|
||||
});
|
||||
|
||||
it('should accept valid command names', () => {
|
||||
expect(() => parseExecutableSpec('qwen')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen-code')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen_code')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen.exe')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('qwen123')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path validation', () => {
|
||||
it('should validate file exists', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/nonexistent/path')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate path points to a file, not directory', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => false,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
|
||||
expect(() => parseExecutableSpec('/path/to/directory')).toThrow(
|
||||
'exists but is not a file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid file paths', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
mockFs.statSync.mockReturnValue({
|
||||
isFile: () => true,
|
||||
} as ReturnType<typeof import('fs').statSync>);
|
||||
|
||||
expect(() => parseExecutableSpec('/path/to/qwen')).not.toThrow();
|
||||
expect(() => parseExecutableSpec('./relative/path')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error message quality', () => {
|
||||
it('should provide helpful error for missing runtime-prefixed file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
|
||||
'Executable file not found at',
|
||||
);
|
||||
expect(() => parseExecutableSpec('tsx:/missing/file.ts')).toThrow(
|
||||
'Please check the file path and ensure the file exists',
|
||||
);
|
||||
});
|
||||
|
||||
it('should provide helpful error for missing regular file', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Set QWEN_CODE_CLI_PATH environment variable',
|
||||
);
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Install qwen globally: npm install -g qwen',
|
||||
);
|
||||
expect(() => parseExecutableSpec('/missing/file')).toThrow(
|
||||
'Force specific runtime: bun:/path/to/cli.js or tsx:/path/to/index.ts',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,350 +0,0 @@
|
||||
/**
|
||||
* Unit tests for createSdkMcpServer
|
||||
*
|
||||
* Tests MCP server creation and tool registration.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js';
|
||||
import { tool } from '../../src/mcp/tool.js';
|
||||
import type { ToolDefinition } from '../../src/types/config.js';
|
||||
|
||||
describe('createSdkMcpServer', () => {
|
||||
describe('Server Creation', () => {
|
||||
it('should create server with name and version', () => {
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', []);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error with invalid name', () => {
|
||||
expect(() => createSdkMcpServer('', '1.0.0', [])).toThrow(
|
||||
'name must be a non-empty string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error with invalid version', () => {
|
||||
expect(() => createSdkMcpServer('test', '', [])).toThrow(
|
||||
'version must be a non-empty string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error with non-array tools', () => {
|
||||
expect(() =>
|
||||
createSdkMcpServer('test', '1.0.0', {} as unknown as ToolDefinition[]),
|
||||
).toThrow('Tools must be an array');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Registration', () => {
|
||||
it('should register single tool', () => {
|
||||
const testTool = tool({
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: { type: 'string' },
|
||||
},
|
||||
},
|
||||
handler: async () => 'result',
|
||||
});
|
||||
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', [testTool]);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it('should register multiple tools', () => {
|
||||
const tool1 = tool({
|
||||
name: 'tool1',
|
||||
description: 'Tool 1',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result1',
|
||||
});
|
||||
|
||||
const tool2 = tool({
|
||||
name: 'tool2',
|
||||
description: 'Tool 2',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result2',
|
||||
});
|
||||
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw error for duplicate tool names', () => {
|
||||
const tool1 = tool({
|
||||
name: 'duplicate',
|
||||
description: 'Tool 1',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result1',
|
||||
});
|
||||
|
||||
const tool2 = tool({
|
||||
name: 'duplicate',
|
||||
description: 'Tool 2',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result2',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
createSdkMcpServer('test-server', '1.0.0', [tool1, tool2]),
|
||||
).toThrow("Duplicate tool name 'duplicate'");
|
||||
});
|
||||
|
||||
it('should validate tool names', () => {
|
||||
const invalidTool = {
|
||||
name: '123invalid', // Starts with number
|
||||
description: 'Invalid tool',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
createSdkMcpServer('test-server', '1.0.0', [
|
||||
invalidTool as unknown as ToolDefinition,
|
||||
]),
|
||||
).toThrow('Tool name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Handler Invocation', () => {
|
||||
it('should invoke tool handler with correct input', async () => {
|
||||
const handler = vi.fn().mockResolvedValue({ result: 'success' });
|
||||
|
||||
const testTool = tool({
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['value'],
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
createSdkMcpServer('test-server', '1.0.0', [testTool]);
|
||||
|
||||
// Note: Actual invocation testing requires MCP SDK integration
|
||||
// This test verifies the handler was properly registered
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle async tool handlers', async () => {
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockImplementation(async (input: { value: string }) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return { processed: input.value };
|
||||
});
|
||||
|
||||
const testTool = tool({
|
||||
name: 'async_tool',
|
||||
description: 'An async tool',
|
||||
inputSchema: { type: 'object' },
|
||||
handler,
|
||||
});
|
||||
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', [testTool]);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Safety', () => {
|
||||
it('should preserve input type in handler', async () => {
|
||||
type ToolInput = {
|
||||
name: string;
|
||||
age: number;
|
||||
};
|
||||
|
||||
type ToolOutput = {
|
||||
greeting: string;
|
||||
};
|
||||
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockImplementation(async (input: ToolInput): Promise<ToolOutput> => {
|
||||
return {
|
||||
greeting: `Hello ${input.name}, age ${input.age}`,
|
||||
};
|
||||
});
|
||||
|
||||
const typedTool = tool<ToolInput, ToolOutput>({
|
||||
name: 'typed_tool',
|
||||
description: 'A typed tool',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
required: ['name', 'age'],
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', [
|
||||
typedTool as ToolDefinition,
|
||||
]);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling in Tools', () => {
|
||||
it('should handle tool handler errors gracefully', async () => {
|
||||
const handler = vi.fn().mockRejectedValue(new Error('Tool failed'));
|
||||
|
||||
const errorTool = tool({
|
||||
name: 'error_tool',
|
||||
description: 'A tool that errors',
|
||||
inputSchema: { type: 'object' },
|
||||
handler,
|
||||
});
|
||||
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
// Error handling occurs during tool invocation
|
||||
});
|
||||
|
||||
it('should handle synchronous tool handler errors', async () => {
|
||||
const handler = vi.fn().mockImplementation(() => {
|
||||
throw new Error('Sync error');
|
||||
});
|
||||
|
||||
const errorTool = tool({
|
||||
name: 'sync_error_tool',
|
||||
description: 'A tool that errors synchronously',
|
||||
inputSchema: { type: 'object' },
|
||||
handler,
|
||||
});
|
||||
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', [errorTool]);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Tool Scenarios', () => {
|
||||
it('should support tool with complex input schema', () => {
|
||||
const complexTool = tool({
|
||||
name: 'complex_tool',
|
||||
description: 'A tool with complex schema',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
filters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: { type: 'string' },
|
||||
minPrice: { type: 'number' },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
handler: async (input: { filters?: unknown[] }) => {
|
||||
return {
|
||||
results: [],
|
||||
filters: input.filters,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', [
|
||||
complexTool as ToolDefinition,
|
||||
]);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
|
||||
it('should support tool returning complex output', () => {
|
||||
const complexOutputTool = tool({
|
||||
name: 'complex_output_tool',
|
||||
description: 'Returns complex data',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => {
|
||||
return {
|
||||
data: [
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
],
|
||||
metadata: {
|
||||
total: 2,
|
||||
page: 1,
|
||||
},
|
||||
nested: {
|
||||
deep: {
|
||||
value: 'test',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const server = createSdkMcpServer('test-server', '1.0.0', [
|
||||
complexOutputTool,
|
||||
]);
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Servers', () => {
|
||||
it('should create multiple independent servers', () => {
|
||||
const tool1 = tool({
|
||||
name: 'tool1',
|
||||
description: 'Tool in server 1',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result1',
|
||||
});
|
||||
|
||||
const tool2 = tool({
|
||||
name: 'tool2',
|
||||
description: 'Tool in server 2',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result2',
|
||||
});
|
||||
|
||||
const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]);
|
||||
const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]);
|
||||
|
||||
expect(server1).toBeDefined();
|
||||
expect(server2).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow same tool name in different servers', () => {
|
||||
const tool1 = tool({
|
||||
name: 'shared_name',
|
||||
description: 'Tool in server 1',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result1',
|
||||
});
|
||||
|
||||
const tool2 = tool({
|
||||
name: 'shared_name',
|
||||
description: 'Tool in server 2',
|
||||
inputSchema: { type: 'object' },
|
||||
handler: async () => 'result2',
|
||||
});
|
||||
|
||||
const server1 = createSdkMcpServer('server1', '1.0.0', [tool1]);
|
||||
const server2 = createSdkMcpServer('server2', '1.0.0', [tool2]);
|
||||
|
||||
expect(server1).toBeDefined();
|
||||
expect(server2).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Language and Environment */
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
/* Emit */
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"removeComments": true,
|
||||
"importHelpers": false,
|
||||
|
||||
/* Interop Constraints */
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
/* Completeness */
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Module Resolution */
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import * as path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'test/',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/index.ts', // Export-only files
|
||||
],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 75,
|
||||
statements: 80,
|
||||
},
|
||||
},
|
||||
include: ['test/**/*.test.ts'],
|
||||
exclude: ['node_modules/', 'dist/'],
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 10000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"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.5",
|
||||
"version": "0.2.0",
|
||||
"publisher": "qwenlm",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -6,7 +6,6 @@ export default defineConfig({
|
||||
'packages/cli',
|
||||
'packages/core',
|
||||
'packages/vscode-ide-companion',
|
||||
'packages/sdk/typescript',
|
||||
'integration-tests',
|
||||
'scripts',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user